diff --git a/.github/workflows/backend-docker-publish.yml b/.github/workflows/backend-docker-publish.yml index fcb26c2..c3160fc 100644 --- a/.github/workflows/backend-docker-publish.yml +++ b/.github/workflows/backend-docker-publish.yml @@ -281,6 +281,9 @@ jobs: echo "Pulling backend image tag: $IMAGE_TAG" IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" pull bitfinance-api + echo "Applying database migrations with image tag: $IMAGE_TAG" + IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" run --rm --no-deps bitfinance-api --migrate + echo "Starting backend service with image tag: $IMAGE_TAG" IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" up -d --no-build bitfinance-api diff --git a/.github/workflows/main-validation.yml b/.github/workflows/main-validation.yml index e86b9cc..c934c48 100644 --- a/.github/workflows/main-validation.yml +++ b/.github/workflows/main-validation.yml @@ -131,6 +131,9 @@ jobs: - name: Build backend run: dotnet build apps/backend/BitFinance.sln --disable-build-servers -v:minimal + - name: Test backend + run: dotnet test apps/backend/BitFinance.sln --no-build --disable-build-servers -v:minimal + mcp: runs-on: ubuntu-latest needs: changes diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8f44f30..f4ebfdf 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -14,3 +14,10 @@ JWT_EXPIRATION=2880 # Cache Settings CACHE_ENABLED=false + +# Notifications (in-app is always enabled; email is opt-in locally) +NOTIFICATIONS_EMAIL_ENABLED=false +RESEND_API_TOKEN= +RESEND_WEBHOOK_SECRET= +NOTIFICATIONS_FROM_ADDRESS=BitFinance +NOTIFICATIONS_FRONTEND_BASE_URL=http://localhost:5174 diff --git a/apps/backend/.env.prod.example b/apps/backend/.env.prod.example index 9990880..10cd85c 100644 --- a/apps/backend/.env.prod.example +++ b/apps/backend/.env.prod.example @@ -30,6 +30,13 @@ CACHE_ENABLED=true # CORS Configuration CORS_ALLOWED_ORIGIN_0= +# Notifications (Resend) +NOTIFICATIONS_EMAIL_ENABLED=true +RESEND_API_TOKEN= +RESEND_WEBHOOK_SECRET= +NOTIFICATIONS_FROM_ADDRESS= +NOTIFICATIONS_FRONTEND_BASE_URL= + # MCP Server Configuration MCP_API_BASE_URL=http://bitfinance-api:8080 MCP_AGENT_EMAIL= diff --git a/apps/backend/BitFinance.sln b/apps/backend/BitFinance.sln index 09fab9e..e696c54 100644 --- a/apps/backend/BitFinance.sln +++ b/apps/backend/BitFinance.sln @@ -81,5 +81,8 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {186A8463-54A5-427F-AEC9-7DB9711EAD04} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {1E020C26-2D18-4BD1-BFA8-F6EA43474315} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2CF72540-C71A-4C72-9BC8-F46C9DF7CDC7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {BFD7E9AA-30E6-41E6-B473-6B9E1159798F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/apps/backend/README.md b/apps/backend/README.md index 9ff9e56..1b58b15 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -21,6 +21,7 @@ BitFinance is a finance platform for tracking bills, expenses, organizations, an - HTTP-only refresh token cookies - Multi-device session management - Subscription plans with plan-based entitlements +- Durable in-app notifications and paid-plan bill reminder emails through Resend - PostgreSQL persistence through Entity Framework Core - Optional Redis caching - Local object storage with MinIO and production S3-compatible storage @@ -115,6 +116,11 @@ The local `.env.example` includes safe development defaults. | `JWT_AUDIENCE` | JWT audience | `bitfinance-dev` | | `JWT_EXPIRATION` | Access token lifetime in minutes | `2880` | | `CACHE_ENABLED` | Enables Redis-backed caching | `false` | +| `NOTIFICATIONS_EMAIL_ENABLED` | Enables Resend delivery for eligible plans | `false` | +| `RESEND_API_TOKEN` | Resend API token | Empty | +| `RESEND_WEBHOOK_SECRET` | Resend/Svix webhook signing secret | Empty | +| `NOTIFICATIONS_FROM_ADDRESS` | Verified sender address | Development placeholder | +| `NOTIFICATIONS_FRONTEND_BASE_URL` | Base URL used by notification email links | `http://localhost:5174` | Production deployments should use `.env.prod.example` as a template and provide real values for database credentials, JWT settings, Azure Key Vault configuration, and image tags. @@ -140,6 +146,9 @@ Primary route groups: - `/api/v1/organizations/{organizationId}/expenses` - `/api/v1/organizations/{organizationId}/dashboard/upcoming-bills` - `/api/v1/organizations/{organizationId}/dashboard/recent-expenses` +- `/api/v1/organizations/{organizationId}/notifications` +- `/api/v1/organizations/{organizationId}/notification-preferences` +- `POST /api/v1/webhooks/resend` Use Scalar at `http://localhost:8080/scalar/v1` for the full interactive API reference. @@ -157,6 +166,12 @@ Apply migrations with: dotnet ef database update --project apps/backend/src/BitFinance.Data --startup-project apps/backend/src/BitFinance.API ``` +Container deployments can run the migration-only command before replacing the API: + +```bash +docker compose run --rm --no-deps bitfinance-api --migrate +``` + In development, the API also applies migrations automatically during startup. ## Docker diff --git a/apps/backend/docker-compose.prod.yml b/apps/backend/docker-compose.prod.yml index f391f8f..9c524ee 100644 --- a/apps/backend/docker-compose.prod.yml +++ b/apps/backend/docker-compose.prod.yml @@ -14,6 +14,11 @@ services: - AZURE_CLIENT_ID=${AZURE_CLIENT_ID} - AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET} - AZURE_TENANT_ID=${AZURE_TENANT_ID} + - Notifications__EmailEnabled=${NOTIFICATIONS_EMAIL_ENABLED} + - Notifications__ResendApiToken=${RESEND_API_TOKEN} + - Notifications__ResendWebhookSecret=${RESEND_WEBHOOK_SECRET} + - Notifications__FromAddress=${NOTIFICATIONS_FROM_ADDRESS} + - Notifications__FrontendBaseUrl=${NOTIFICATIONS_FRONTEND_BASE_URL} deploy: resources: limits: diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index db52203..c947d16 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -16,7 +16,7 @@ services: - ASPNETCORE_URLS=http://+:8080 - ConnectionStrings__Database=Host=bitfinance-db;Port=5432;Database=${DB_NAME:-bitfinance};Username=${DB_USER:-postgres};Password=${DB_PASSWORD:-postgres} - ConnectionStrings__Cache=${REDIS_CONNECTION_STRING:-bitfinance-cache:6379} - - AppSettings__CacheEnabled=${CACHE_ENABLED:-true} + - AppSettings__CacheEnabled=${CACHE_ENABLED:-false} - AppSettings__LoggingEnabled=true - Logging__LogLevel__Default=Warning - Logging__LogLevel__Microsoft.AspNetCore=Warning @@ -26,6 +26,11 @@ services: - Jwt__ExpirationInMinutes=${JWT_EXPIRATION:-60} - Storage__BucketName=${S3_BUCKET_NAME:-bitfinance} - Storage__Region=${S3_REGION:-us-east-1} + - Notifications__EmailEnabled=${NOTIFICATIONS_EMAIL_ENABLED:-false} + - Notifications__ResendApiToken=${RESEND_API_TOKEN:-} + - Notifications__ResendWebhookSecret=${RESEND_WEBHOOK_SECRET:-} + - Notifications__FromAddress=${NOTIFICATIONS_FROM_ADDRESS:-} + - Notifications__FrontendBaseUrl=${NOTIFICATIONS_FRONTEND_BASE_URL:-http://localhost:5174} restart: unless-stopped networks: - bitfinance-network @@ -56,6 +61,8 @@ services: bitfinance-cache: image: redis:7-alpine container_name: bitfinance-cache + profiles: + - cache networks: - bitfinance-network restart: unless-stopped diff --git a/apps/backend/scripts/migrations/V014__add_notifications.sql b/apps/backend/scripts/migrations/V014__add_notifications.sql new file mode 100644 index 0000000..d59f2fc --- /dev/null +++ b/apps/backend/scripts/migrations/V014__add_notifications.sql @@ -0,0 +1,163 @@ +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE TABLE notification_outbox_messages ( + id uuid NOT NULL, + organization_id uuid NOT NULL, + type character varying(64) NOT NULL, + aggregate_id character varying(128), + deduplication_key character varying(256) NOT NULL, + payload_json jsonb NOT NULL, + created_at timestamp(3) with time zone NOT NULL, + processed_at timestamp(3) with time zone, + attempts integer NOT NULL, + next_attempt_at timestamp(3) with time zone NOT NULL, + locked_until timestamp(3) with time zone, + last_error character varying(2000), + CONSTRAINT pk_notification_outbox_messages PRIMARY KEY (id) + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE TABLE notification_preferences ( + user_id text NOT NULL, + organization_id uuid NOT NULL, + email_bill_reminders_enabled boolean NOT NULL DEFAULT TRUE, + created_at timestamp(3) with time zone NOT NULL, + updated_at timestamp(3) with time zone, + CONSTRAINT "PK_notification_preferences" PRIMARY KEY (user_id, organization_id), + CONSTRAINT fk_notification_preferences_asp_net_users_user_id FOREIGN KEY (user_id) REFERENCES asp_net_users (id) ON DELETE CASCADE, + CONSTRAINT fk_notification_preferences_organizations_organization_id FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE TABLE notifications ( + id uuid NOT NULL, + source_event_id uuid NOT NULL, + organization_id uuid NOT NULL, + recipient_user_id text NOT NULL, + type character varying(64) NOT NULL, + payload_json jsonb NOT NULL, + action_path character varying(500) NOT NULL, + created_at timestamp(3) with time zone NOT NULL, + read_at timestamp(3) with time zone, + CONSTRAINT pk_notifications PRIMARY KEY (id), + CONSTRAINT fk_notifications_asp_net_users_recipient_user_id FOREIGN KEY (recipient_user_id) REFERENCES asp_net_users (id) ON DELETE CASCADE, + CONSTRAINT fk_notifications_organizations_organization_id FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE TABLE provider_webhook_receipts ( + provider_event_id character varying(256) NOT NULL, + received_at timestamp(3) with time zone NOT NULL, + CONSTRAINT "PK_provider_webhook_receipts" PRIMARY KEY (provider_event_id) + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE TABLE notification_deliveries ( + id uuid NOT NULL, + notification_id uuid NOT NULL, + channel character varying(32) NOT NULL, + status character varying(32) NOT NULL, + attempts integer NOT NULL, + next_attempt_at timestamp(3) with time zone NOT NULL, + locked_until timestamp(3) with time zone, + provider_message_id character varying(256), + provider_event_at timestamp(3) with time zone, + sent_at timestamp(3) with time zone, + last_error character varying(2000), + CONSTRAINT pk_notification_deliveries PRIMARY KEY (id), + CONSTRAINT fk_notification_deliveries_notifications_notification_id FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE UNIQUE INDEX "IX_notification_deliveries_notification_id_channel" ON notification_deliveries (notification_id, channel); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX "IX_notification_deliveries_provider_message_id" ON notification_deliveries (provider_message_id); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX "IX_notification_deliveries_status_next_attempt_at_locked_until" ON notification_deliveries (status, next_attempt_at, locked_until); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE UNIQUE INDEX "IX_notification_outbox_messages_deduplication_key" ON notification_outbox_messages (deduplication_key); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX "IX_notification_outbox_messages_processed_at_next_attempt_at_l~" ON notification_outbox_messages (processed_at, next_attempt_at, locked_until); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX ix_notification_preferences_organization_id ON notification_preferences (organization_id); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX "IX_notifications_organization_id_recipient_user_id_read_at_cre~" ON notifications (organization_id, recipient_user_id, read_at, created_at); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE INDEX ix_notifications_recipient_user_id ON notifications (recipient_user_id); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + CREATE UNIQUE INDEX "IX_notifications_source_event_id_recipient_user_id" ON notifications (source_event_id, recipient_user_id); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260715040314_AddNotifications', '10.0.0'); + END IF; +END $EF$; +COMMIT; + diff --git a/apps/backend/src/BitFinance.API/BitFinance.API.csproj b/apps/backend/src/BitFinance.API/BitFinance.API.csproj index c96b1dd..4f6adac 100644 --- a/apps/backend/src/BitFinance.API/BitFinance.API.csproj +++ b/apps/backend/src/BitFinance.API/BitFinance.API.csproj @@ -35,6 +35,7 @@ + diff --git a/apps/backend/src/BitFinance.API/Controllers/NotificationsController.cs b/apps/backend/src/BitFinance.API/Controllers/NotificationsController.cs new file mode 100644 index 0000000..0d46a28 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Controllers/NotificationsController.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using Asp.Versioning; +using BitFinance.API.Attributes; +using BitFinance.API.Models; +using BitFinance.API.Models.Request; +using BitFinance.API.Models.Response; +using BitFinance.API.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BitFinance.API.Controllers; + +[ApiController] +[Authorize] +[OrganizationAuthorization] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/organizations/{organizationId:guid}")] +public sealed class NotificationsController(INotificationService notificationService) : ControllerBase +{ + [HttpGet("notifications")] + public async Task>> GetNotifications( + Guid organizationId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool unreadOnly = false, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + return Ok(await notificationService.GetAsync( + organizationId, userId, page, pageSize, unreadOnly, cancellationToken)); + } + + [HttpGet("notifications/unread-count")] + public async Task> GetUnreadCount( + Guid organizationId, + CancellationToken cancellationToken) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + var count = await notificationService.GetUnreadCountAsync(organizationId, userId, cancellationToken); + return Ok(new NotificationUnreadCountResponse(count)); + } + + [HttpPatch("notifications/{notificationId:guid}/read")] + public async Task MarkRead(Guid organizationId, Guid notificationId, CancellationToken cancellationToken) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + return await notificationService.MarkReadAsync( + organizationId, userId, notificationId, cancellationToken) ? NoContent() : NotFound(); + } + + [HttpPost("notifications/read-all")] + public async Task MarkAllRead(Guid organizationId, CancellationToken cancellationToken) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + await notificationService.MarkAllReadAsync(organizationId, userId, cancellationToken); + return NoContent(); + } + + [HttpGet("notification-preferences")] + public async Task> GetPreferences( + Guid organizationId, + CancellationToken cancellationToken) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + return Ok(await notificationService.GetPreferencesAsync(organizationId, userId, cancellationToken)); + } + + [HttpPut("notification-preferences")] + public async Task> UpdatePreferences( + Guid organizationId, + [FromBody] UpdateNotificationPreferenceRequest request, + CancellationToken cancellationToken) + { + var userId = GetUserId(); + if (userId is null) return Unauthorized(); + return Ok(await notificationService.UpdatePreferencesAsync( + organizationId, userId, request.EmailBillRemindersEnabled, cancellationToken)); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/apps/backend/src/BitFinance.API/Controllers/ResendWebhooksController.cs b/apps/backend/src/BitFinance.API/Controllers/ResendWebhooksController.cs new file mode 100644 index 0000000..cdf9766 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Controllers/ResendWebhooksController.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Asp.Versioning; +using BitFinance.API.Settings; +using BitFinance.Business.Entities; +using BitFinance.Business.Enums; +using BitFinance.Data.Contexts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Svix; +using Svix.Exceptions; + +namespace BitFinance.API.Controllers; + +[ApiController] +[AllowAnonymous] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/webhooks/resend")] +public sealed class ResendWebhooksController( + ApplicationDbContext dbContext, + IOptions options, + ILogger logger) : ControllerBase +{ + [HttpPost] + public async Task Receive(CancellationToken cancellationToken) + { + var secret = options.Value.ResendWebhookSecret; + if (string.IsNullOrWhiteSpace(secret)) + return StatusCode(StatusCodes.Status503ServiceUnavailable); + + using var reader = new StreamReader(Request.Body); + var payload = await reader.ReadToEndAsync(cancellationToken); + try + { + new Webhook(secret).Verify(payload, header => Request.Headers[header ?? string.Empty].FirstOrDefault()); + } + catch (WebhookVerificationException exception) + { + logger.LogWarning(exception, "Rejected invalid Resend webhook signature"); + return BadRequest(); + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(payload); + } + catch (JsonException exception) + { + logger.LogWarning(exception, "Rejected malformed Resend webhook payload"); + return BadRequest(); + } + + using (document) + { + var root = document.RootElement; + var providerEventId = Request.Headers["svix-id"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(providerEventId)) return BadRequest(); + if (await dbContext.ProviderWebhookReceipts.AnyAsync( + receipt => receipt.ProviderEventId == providerEventId, cancellationToken)) + return Ok(); + + var eventType = root.TryGetProperty("type", out var typeElement) ? typeElement.GetString() : null; + var eventAt = root.TryGetProperty("created_at", out var createdElement) + && createdElement.TryGetDateTime(out var parsedEventAt) + ? parsedEventAt.ToUniversalTime() + : DateTime.UtcNow; + string? providerMessageId = null; + if (root.TryGetProperty("data", out var data)) + { + if (data.TryGetProperty("email_id", out var emailId)) providerMessageId = emailId.GetString(); + else if (data.TryGetProperty("id", out var id)) providerMessageId = id.GetString(); + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + dbContext.ProviderWebhookReceipts.Add(new ProviderWebhookReceipt { ProviderEventId = providerEventId }); + if (!string.IsNullOrWhiteSpace(providerMessageId)) + { + var delivery = await dbContext.NotificationDeliveries.FirstOrDefaultAsync( + item => item.ProviderMessageId == providerMessageId, cancellationToken); + if (delivery is not null && (delivery.ProviderEventAt is null || delivery.ProviderEventAt < eventAt)) + { + delivery.ProviderEventAt = eventAt; + delivery.Status = eventType switch + { + "email.sent" => NotificationDeliveryStatus.Sent, + "email.delivered" => NotificationDeliveryStatus.Delivered, + "email.bounced" => NotificationDeliveryStatus.Bounced, + "email.failed" => NotificationDeliveryStatus.Failed, + _ => delivery.Status, + }; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(); + } + } +} diff --git a/apps/backend/src/BitFinance.API/Extensions/CachingExtensions.cs b/apps/backend/src/BitFinance.API/Extensions/CachingExtensions.cs index fbf92e3..0f16666 100644 --- a/apps/backend/src/BitFinance.API/Extensions/CachingExtensions.cs +++ b/apps/backend/src/BitFinance.API/Extensions/CachingExtensions.cs @@ -4,12 +4,21 @@ public static class CachingExtensions { public static IServiceCollection AddCaching(this IServiceCollection services, IConfiguration configuration) { - services.AddStackExchangeRedisCache(options => + var cacheEnabled = configuration.GetValue("AppSettings:CacheEnabled"); + + if (cacheEnabled) + { + services.AddStackExchangeRedisCache(options => + { + options.Configuration = configuration.GetConnectionString("Cache"); + options.InstanceName = "BitFinance"; + }); + } + else { - options.Configuration = configuration.GetConnectionString("Cache"); - options.InstanceName = "BitFinance"; - }); - + services.AddDistributedMemoryCache(); + } + return services; } } \ No newline at end of file diff --git a/apps/backend/src/BitFinance.API/Extensions/ServiceCollectionExtensions.cs b/apps/backend/src/BitFinance.API/Extensions/ServiceCollectionExtensions.cs index 9ecb42e..3acf805 100644 --- a/apps/backend/src/BitFinance.API/Extensions/ServiceCollectionExtensions.cs +++ b/apps/backend/src/BitFinance.API/Extensions/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ public static IServiceCollection AddDependencyInjection(this IServiceCollection { services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddScoped(); services.AddScoped(); @@ -40,6 +41,26 @@ public static IServiceCollection AddDependencyInjection(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton, NotificationOptionsValidator>(); + services.AddOptions() + .Bind(configuration.GetSection(NotificationOptions.SectionName)) + .ValidateOnStart(); + if (configuration.GetValue("Notifications:EmailEnabled")) + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.resend.com/"); + client.Timeout = TimeSpan.FromSeconds(20); + }); + } + else + { + services.AddSingleton(); + } services.AddSingleton, JwtSettingsValidator>(); services.AddOptions() diff --git a/apps/backend/src/BitFinance.API/Models/Request/UpdateNotificationPreferenceRequest.cs b/apps/backend/src/BitFinance.API/Models/Request/UpdateNotificationPreferenceRequest.cs new file mode 100644 index 0000000..859bcd1 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Models/Request/UpdateNotificationPreferenceRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace BitFinance.API.Models.Request; + +public sealed class UpdateNotificationPreferenceRequest +{ + [Required] + public bool EmailBillRemindersEnabled { get; init; } +} diff --git a/apps/backend/src/BitFinance.API/Models/Response/NotificationResponse.cs b/apps/backend/src/BitFinance.API/Models/Response/NotificationResponse.cs new file mode 100644 index 0000000..bef9a10 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Models/Response/NotificationResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace BitFinance.API.Models.Response; + +public sealed record NotificationResponse( + Guid Id, + string Type, + JsonElement Parameters, + string ActionPath, + DateTime CreatedAt, + DateTime? ReadAt); + +public sealed record NotificationUnreadCountResponse(int Count); + +public sealed record NotificationPreferenceResponse(bool EmailBillRemindersEnabled, bool EmailAvailable); diff --git a/apps/backend/src/BitFinance.API/Program.cs b/apps/backend/src/BitFinance.API/Program.cs index 4c85cb7..a960975 100644 --- a/apps/backend/src/BitFinance.API/Program.cs +++ b/apps/backend/src/BitFinance.API/Program.cs @@ -1,4 +1,6 @@ using BitFinance.API.Extensions; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -16,6 +18,14 @@ var app = builder.Build(); +if (args.Contains("--migrate", StringComparer.OrdinalIgnoreCase)) +{ + await using var scope = app.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + return; +} + app.ConfigureMiddleware(builder.Configuration); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/apps/backend/src/BitFinance.API/Services/BillStatusWorkerService.cs b/apps/backend/src/BitFinance.API/Services/BillStatusWorkerService.cs index a0e57ca..9fcd560 100644 --- a/apps/backend/src/BitFinance.API/Services/BillStatusWorkerService.cs +++ b/apps/backend/src/BitFinance.API/Services/BillStatusWorkerService.cs @@ -2,6 +2,8 @@ using BitFinance.Business.Entities; using BitFinance.Business.Enums; using BitFinance.Data.Repositories.Interfaces; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; namespace BitFinance.API.Services; @@ -30,6 +32,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await GenerateScheduledBills(); await UpdateUpcomingBills(); await UpdateDueBills(); + await EnqueueBillReminders(); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -54,6 +57,46 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } } + + private async Task EnqueueBillReminders() + { + using var scope = _serviceScopeFactory.CreateScope(); + var organizationsRepository = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var notificationService = scope.ServiceProvider.GetRequiredService(); + var organizations = await organizationsRepository.GetAllAsync(); + + foreach (var organization in organizations) + { + var today = organization.GetCurrentLocalDate(); + var dueSoon = today.AddDays(3); + var bills = await dbContext.Bills.AsNoTracking() + .Where(bill => bill.OrganizationId == organization.Id + && bill.Status != BillStatus.Paid + && bill.Status != BillStatus.Cancelled + && (bill.DueDate == dueSoon + || bill.DueDate == today + || (bill.DueDate < today && bill.Status == BillStatus.Overdue))) + .ToListAsync(); + + foreach (var bill in bills) + { + var type = NotificationRules.GetBillReminderType(bill.DueDate, today, bill.Status); + if (type is null) continue; + + await notificationService.EnqueueAsync( + organization.Id, + type.Value, + bill.Id.ToString(), + $"bill:{bill.Id:N}:{type.Value}", + new NotificationEventPayload( + BillId: bill.Id, + BillDescription: bill.Description, + AmountDue: bill.AmountDue, + DueDate: bill.DueDate)); + } + } + } private async Task GenerateScheduledBills() { diff --git a/apps/backend/src/BitFinance.API/Services/DisabledEmailSender.cs b/apps/backend/src/BitFinance.API/Services/DisabledEmailSender.cs new file mode 100644 index 0000000..de857f8 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/DisabledEmailSender.cs @@ -0,0 +1,14 @@ +using BitFinance.API.Services.Interfaces; + +namespace BitFinance.API.Services; + +public sealed class DisabledEmailSender : IEmailSender +{ + public bool IsConfigured => false; + + public Task SendBillReminderAsync( + BillReminderEmail message, + Guid idempotencyKey, + CancellationToken cancellationToken) => + Task.FromResult(new EmailSendResult(false, Error: "Email delivery is disabled.")); +} diff --git a/apps/backend/src/BitFinance.API/Services/EfTransactionRunner.cs b/apps/backend/src/BitFinance.API/Services/EfTransactionRunner.cs new file mode 100644 index 0000000..af992cb --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/EfTransactionRunner.cs @@ -0,0 +1,14 @@ +using BitFinance.API.Services.Interfaces; +using BitFinance.Data.Contexts; + +namespace BitFinance.API.Services; + +public sealed class EfTransactionRunner(ApplicationDbContext dbContext) : ITransactionRunner +{ + public async Task ExecuteAsync(Func operation, CancellationToken cancellationToken = default) + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await operation(); + await transaction.CommitAsync(cancellationToken); + } +} diff --git a/apps/backend/src/BitFinance.API/Services/Interfaces/IEmailSender.cs b/apps/backend/src/BitFinance.API/Services/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..241af93 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/Interfaces/IEmailSender.cs @@ -0,0 +1,19 @@ +namespace BitFinance.API.Services.Interfaces; + +public interface IEmailSender +{ + bool IsConfigured { get; } + Task SendBillReminderAsync(BillReminderEmail message, Guid idempotencyKey, CancellationToken cancellationToken); +} + +public sealed record BillReminderEmail( + string RecipientEmail, + string RecipientName, + string OrganizationName, + string BillDescription, + decimal AmountDue, + DateOnly DueDate, + string ReminderType, + string ActionUrl); + +public sealed record EmailSendResult(bool Success, string? ProviderMessageId = null, string? Error = null); diff --git a/apps/backend/src/BitFinance.API/Services/Interfaces/INotificationService.cs b/apps/backend/src/BitFinance.API/Services/Interfaces/INotificationService.cs new file mode 100644 index 0000000..c4dfd3f --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/Interfaces/INotificationService.cs @@ -0,0 +1,32 @@ +using BitFinance.API.Models; +using BitFinance.API.Models.Response; +using BitFinance.Business.Enums; + +namespace BitFinance.API.Services.Interfaces; + +public interface INotificationService +{ + Task EnqueueAsync(Guid organizationId, NotificationType type, string? aggregateId, string deduplicationKey, + NotificationEventPayload payload, CancellationToken cancellationToken = default); + Task> GetAsync(Guid organizationId, string userId, int page, int pageSize, + bool unreadOnly, CancellationToken cancellationToken = default); + Task GetUnreadCountAsync(Guid organizationId, string userId, CancellationToken cancellationToken = default); + Task MarkReadAsync(Guid organizationId, string userId, Guid notificationId, + CancellationToken cancellationToken = default); + Task MarkAllReadAsync(Guid organizationId, string userId, CancellationToken cancellationToken = default); + Task GetPreferencesAsync(Guid organizationId, string userId, + CancellationToken cancellationToken = default); + Task UpdatePreferencesAsync(Guid organizationId, string userId, bool enabled, + CancellationToken cancellationToken = default); +} + +public sealed record NotificationEventPayload( + Guid? BillId = null, + string? BillDescription = null, + decimal? AmountDue = null, + DateOnly? DueDate = null, + string? MemberUserId = null, + string? MemberName = null, + string? ActorName = null, + string? PreviousRole = null, + string? NewRole = null); diff --git a/apps/backend/src/BitFinance.API/Services/Interfaces/ITransactionRunner.cs b/apps/backend/src/BitFinance.API/Services/Interfaces/ITransactionRunner.cs new file mode 100644 index 0000000..8e28f0b --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/Interfaces/ITransactionRunner.cs @@ -0,0 +1,6 @@ +namespace BitFinance.API.Services.Interfaces; + +public interface ITransactionRunner +{ + Task ExecuteAsync(Func operation, CancellationToken cancellationToken = default); +} diff --git a/apps/backend/src/BitFinance.API/Services/InvitationsService.cs b/apps/backend/src/BitFinance.API/Services/InvitationsService.cs index 950b6f9..0025c6d 100644 --- a/apps/backend/src/BitFinance.API/Services/InvitationsService.cs +++ b/apps/backend/src/BitFinance.API/Services/InvitationsService.cs @@ -11,13 +11,19 @@ public class InvitationsService : IInvitationsService { private readonly IInvitationsRepository _invitationsRepository; private readonly IOrganizationsRepository _organizationsRepository; + private readonly INotificationService _notificationService; + private readonly ITransactionRunner _transactionRunner; public InvitationsService( IInvitationsRepository invitationsRepository, - IOrganizationsRepository organizationsRepository) + IOrganizationsRepository organizationsRepository, + INotificationService notificationService, + ITransactionRunner transactionRunner) { _invitationsRepository = invitationsRepository; _organizationsRepository = organizationsRepository; + _notificationService = notificationService; + _transactionRunner = transactionRunner; } public async Task CreateInvitationAsync( @@ -88,18 +94,30 @@ public async Task JoinOrganizationAsync(string token, st if (organization.Members.Any(m => m.UserId == userId)) return JoinOrganizationResult.Failed(JoinOrganizationError.AlreadyMember, "You are already a member of this organization"); - organization.Members.Add(new OrganizationMember + var member = new OrganizationMember { UserId = userId, OrganizationId = organization.Id, Role = invitation.Role, JoinedAt = DateTime.UtcNow, - }); + }; + organization.Members.Add(member); invitation.Status = InvitationStatus.Accepted; - await _organizationsRepository.UpdateAsync(organization); - await _invitationsRepository.UpdateAsync(invitation); + await _transactionRunner.ExecuteAsync(async () => + { + await _organizationsRepository.UpdateAsync(organization); + await _invitationsRepository.UpdateAsync(invitation); + await _notificationService.EnqueueAsync( + organization.Id, + NotificationType.MemberJoined, + userId, + $"membership:joined:{invitation.Id:N}", + new NotificationEventPayload( + MemberUserId: userId, + MemberName: userEmail)); + }); return JoinOrganizationResult.Succeeded(organization); } diff --git a/apps/backend/src/BitFinance.API/Services/NotificationDispatchWorkerService.cs b/apps/backend/src/BitFinance.API/Services/NotificationDispatchWorkerService.cs new file mode 100644 index 0000000..c04589d --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/NotificationDispatchWorkerService.cs @@ -0,0 +1,46 @@ +namespace BitFinance.API.Services; + +public sealed class NotificationDispatchWorkerService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private DateOnly _lastCleanupDate; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + await dispatcher.ProcessAsync(stoppingToken); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastCleanupDate != today) + { + await dispatcher.CleanupAsync(stoppingToken); + _lastCleanupDate = today; + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Notification dispatcher cycle failed"); + } + + try + { + await Task.Delay(PollInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } +} diff --git a/apps/backend/src/BitFinance.API/Services/NotificationDispatcher.cs b/apps/backend/src/BitFinance.API/Services/NotificationDispatcher.cs new file mode 100644 index 0000000..baecfcd --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/NotificationDispatcher.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using BitFinance.API.Services.Interfaces; +using BitFinance.API.Settings; +using BitFinance.Business.Entities; +using BitFinance.Business.Enums; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BitFinance.API.Services; + +public sealed class NotificationDispatcher( + ApplicationDbContext dbContext, + IEmailSender emailSender, + IOptions options, + ILogger logger) +{ + private const int BatchSize = 50; + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly NotificationOptions _options = options.Value; + + public async Task ProcessAsync(CancellationToken cancellationToken) + { + foreach (var messageId in await ClaimOutboxAsync(cancellationToken)) + await ProcessOutboxMessageAsync(messageId, cancellationToken); + + foreach (var deliveryId in await ClaimDeliveriesAsync(cancellationToken)) + await ProcessDeliveryAsync(deliveryId, cancellationToken); + } + + public async Task CleanupAsync(CancellationToken cancellationToken) + { + var notificationCutoff = DateTime.UtcNow.AddDays(-90); + var infrastructureCutoff = DateTime.UtcNow.AddDays(-30); + + await dbContext.Notifications + .Where(notification => notification.ReadAt != null && notification.ReadAt < notificationCutoff) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.NotificationOutboxMessages + .Where(message => message.ProcessedAt != null && message.ProcessedAt < infrastructureCutoff) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.ProviderWebhookReceipts + .Where(receipt => receipt.ReceivedAt < infrastructureCutoff) + .ExecuteDeleteAsync(cancellationToken); + } + + private async Task> ClaimOutboxAsync(CancellationToken cancellationToken) + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var now = DateTime.UtcNow; + var messages = await dbContext.NotificationOutboxMessages + .FromSqlInterpolated($$""" + SELECT * FROM notification_outbox_messages + WHERE processed_at IS NULL + AND next_attempt_at <= {{now}} + AND (locked_until IS NULL OR locked_until < {{now}}) + ORDER BY created_at + LIMIT {{BatchSize}} + FOR UPDATE SKIP LOCKED + """) + .ToListAsync(cancellationToken); + + foreach (var message in messages) + message.LockedUntil = now.Add(LeaseDuration); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + var ids = messages.Select(message => message.Id).ToList(); + dbContext.ChangeTracker.Clear(); + return ids; + } + + private async Task ProcessOutboxMessageAsync(Guid messageId, CancellationToken cancellationToken) + { + var message = await dbContext.NotificationOutboxMessages.FirstOrDefaultAsync( + item => item.Id == messageId, cancellationToken); + if (message is null) return; + + try + { + var organization = await dbContext.Organizations + .Include(item => item.Members) + .ThenInclude(member => member.User) + .FirstOrDefaultAsync(item => item.Id == message.OrganizationId, cancellationToken); + if (organization is null) + { + message.ProcessedAt = DateTime.UtcNow; + message.LockedUntil = null; + await dbContext.SaveChangesAsync(cancellationToken); + return; + } + + var payload = JsonSerializer.Deserialize(message.PayloadJson, JsonOptions) + ?? new NotificationEventPayload(); + var recipientIds = IsBillType(message.Type) + ? organization.Members.Select(member => member.UserId).Distinct(StringComparer.Ordinal).ToList() + : NotificationRules.GetMembershipRecipientIds(organization.Members).ToList(); + var actionPath = IsBillType(message.Type) && payload.BillId is { } billId + ? $"/dashboard/bills/{billId}" + : "/organization/members"; + + foreach (var recipientId in recipientIds) + { + var exists = await dbContext.Notifications.AnyAsync(notification => + notification.SourceEventId == message.Id && notification.RecipientUserId == recipientId, + cancellationToken); + if (exists) continue; + + var notification = new Notification + { + SourceEventId = message.Id, + OrganizationId = message.OrganizationId, + RecipientUserId = recipientId, + Type = message.Type, + PayloadJson = message.PayloadJson, + ActionPath = actionPath, + }; + dbContext.Notifications.Add(notification); + + if (!IsBillType(message.Type) || !emailSender.IsConfigured) continue; + var preferenceEnabled = await dbContext.NotificationPreferences + .Where(preference => preference.OrganizationId == organization.Id && preference.UserId == recipientId) + .Select(preference => (bool?)preference.EmailBillRemindersEnabled) + .FirstOrDefaultAsync(cancellationToken) ?? true; + if (NotificationRules.CanSendBillEmail( + organization.EffectivePlanTier, preferenceEnabled, emailSender.IsConfigured)) + { + notification.Deliveries.Add(new NotificationDelivery()); + } + } + + message.ProcessedAt = DateTime.UtcNow; + message.LockedUntil = null; + message.LastError = null; + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Notification outbox message {MessageId} failed", messageId); + dbContext.ChangeTracker.Clear(); + message = await dbContext.NotificationOutboxMessages.FirstAsync(item => item.Id == messageId, cancellationToken); + message.Attempts++; + message.LockedUntil = null; + message.LastError = $"{exception.GetType().Name}: notification dispatch failed"; + var nextAttempt = NotificationRetryPolicy.GetNextAttemptAt(message.Attempts, DateTime.UtcNow); + if (nextAttempt is null) + message.ProcessedAt = DateTime.UtcNow; + else + message.NextAttemptAt = nextAttempt.Value; + await dbContext.SaveChangesAsync(cancellationToken); + } + finally + { + dbContext.ChangeTracker.Clear(); + } + } + + private async Task> ClaimDeliveriesAsync(CancellationToken cancellationToken) + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var now = DateTime.UtcNow; + var deliveries = await dbContext.NotificationDeliveries + .FromSqlInterpolated($$""" + SELECT * FROM notification_deliveries + WHERE status IN ('Pending', 'Processing') + AND next_attempt_at <= {{now}} + AND (locked_until IS NULL OR locked_until < {{now}}) + ORDER BY next_attempt_at + LIMIT {{BatchSize}} + FOR UPDATE SKIP LOCKED + """) + .ToListAsync(cancellationToken); + + foreach (var delivery in deliveries) + { + delivery.Status = NotificationDeliveryStatus.Processing; + delivery.LockedUntil = now.Add(LeaseDuration); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + var ids = deliveries.Select(delivery => delivery.Id).ToList(); + dbContext.ChangeTracker.Clear(); + return ids; + } + + private async Task ProcessDeliveryAsync(Guid deliveryId, CancellationToken cancellationToken) + { + var delivery = await dbContext.NotificationDeliveries + .Include(item => item.Notification) + .ThenInclude(notification => notification.RecipientUser) + .Include(item => item.Notification) + .ThenInclude(notification => notification.Organization) + .FirstOrDefaultAsync(item => item.Id == deliveryId, cancellationToken); + if (delivery is null) return; + + var notification = delivery.Notification; + var organization = notification.Organization; + var recipient = notification.RecipientUser; + var stillMember = await dbContext.OrganizationMembers.AnyAsync(member => + member.OrganizationId == organization.Id && member.UserId == recipient.Id, cancellationToken); + var preferenceEnabled = await dbContext.NotificationPreferences + .Where(preference => preference.OrganizationId == organization.Id && preference.UserId == recipient.Id) + .Select(preference => (bool?)preference.EmailBillRemindersEnabled) + .FirstOrDefaultAsync(cancellationToken) ?? true; + + if (!stillMember + || string.IsNullOrWhiteSpace(recipient.Email) + || !NotificationRules.CanSendBillEmail(organization.EffectivePlanTier, preferenceEnabled, emailSender.IsConfigured)) + { + delivery.Status = NotificationDeliveryStatus.Suppressed; + delivery.LockedUntil = null; + delivery.LastError = null; + await dbContext.SaveChangesAsync(cancellationToken); + dbContext.ChangeTracker.Clear(); + return; + } + + try + { + var payload = JsonSerializer.Deserialize(notification.PayloadJson, JsonOptions) + ?? throw new InvalidOperationException("Notification payload is invalid."); + var actionUrl = new Uri(new Uri(_options.FrontendBaseUrl.TrimEnd('/') + "/"), notification.ActionPath.TrimStart('/')).ToString(); + var result = await emailSender.SendBillReminderAsync(new BillReminderEmail( + recipient.Email, + recipient.FullName, + organization.Name, + payload.BillDescription ?? "Conta", + payload.AmountDue ?? 0, + payload.DueDate ?? DateOnly.FromDateTime(DateTime.UtcNow), + notification.Type.ToString(), + actionUrl), delivery.Id, cancellationToken); + + if (result.Success) + { + delivery.Status = NotificationDeliveryStatus.Sent; + delivery.ProviderMessageId = result.ProviderMessageId; + delivery.SentAt = DateTime.UtcNow; + delivery.LockedUntil = null; + delivery.LastError = null; + } + else + { + ScheduleDeliveryRetry(delivery, result.Error ?? "Email provider rejected the request."); + } + } + catch (Exception exception) + { + logger.LogError(exception, "Notification delivery {DeliveryId} failed", deliveryId); + ScheduleDeliveryRetry(delivery, $"{exception.GetType().Name}: email delivery failed"); + } + + await dbContext.SaveChangesAsync(cancellationToken); + dbContext.ChangeTracker.Clear(); + } + + private static void ScheduleDeliveryRetry(NotificationDelivery delivery, string error) + { + delivery.Attempts++; + delivery.LockedUntil = null; + delivery.LastError = error.Length > 2000 ? error[..2000] : error; + var nextAttempt = NotificationRetryPolicy.GetNextAttemptAt(delivery.Attempts, DateTime.UtcNow); + if (nextAttempt is null) + delivery.Status = NotificationDeliveryStatus.Failed; + else + { + delivery.Status = NotificationDeliveryStatus.Pending; + delivery.NextAttemptAt = nextAttempt.Value; + } + } + + private static bool IsBillType(NotificationType type) => type is + NotificationType.BillDueSoon or NotificationType.BillDueToday or NotificationType.BillOverdue; +} diff --git a/apps/backend/src/BitFinance.API/Services/NotificationRetryPolicy.cs b/apps/backend/src/BitFinance.API/Services/NotificationRetryPolicy.cs new file mode 100644 index 0000000..4ccc76c --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/NotificationRetryPolicy.cs @@ -0,0 +1,15 @@ +namespace BitFinance.API.Services; + +public static class NotificationRetryPolicy +{ + private static readonly TimeSpan[] Delays = + [ + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(5), + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(2), + ]; + + public static DateTime? GetNextAttemptAt(int attempt, DateTime now) => + attempt >= 1 && attempt <= Delays.Length ? now.Add(Delays[attempt - 1]) : null; +} diff --git a/apps/backend/src/BitFinance.API/Services/NotificationRules.cs b/apps/backend/src/BitFinance.API/Services/NotificationRules.cs new file mode 100644 index 0000000..31fdf04 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/NotificationRules.cs @@ -0,0 +1,39 @@ +using BitFinance.Business.Entities; +using BitFinance.Business.Enums; + +namespace BitFinance.API.Services; + +public static class NotificationRules +{ + public static NotificationType? GetBillReminderType(DateOnly dueDate, DateOnly today, BillStatus status) + { + if (status is BillStatus.Paid or BillStatus.Cancelled) + return null; + + if (dueDate == today.AddDays(3)) + return NotificationType.BillDueSoon; + + if (dueDate == today) + return NotificationType.BillDueToday; + + if (dueDate < today && status == BillStatus.Overdue) + return NotificationType.BillOverdue; + + return null; + } + + public static IReadOnlyList GetMembershipRecipientIds(IEnumerable members) => + members + .Where(member => member.Role is OrgRole.Owner or OrgRole.Admin) + .Select(member => member.UserId) + .Distinct(StringComparer.Ordinal) + .ToList(); + + public static bool CanSendBillEmail( + PlanTier effectivePlanTier, + bool preferenceEnabled, + bool deliveryConfigured) => + deliveryConfigured + && preferenceEnabled + && PlanEntitlement.For(effectivePlanTier).HasEmailNotifications; +} diff --git a/apps/backend/src/BitFinance.API/Services/NotificationService.cs b/apps/backend/src/BitFinance.API/Services/NotificationService.cs new file mode 100644 index 0000000..a448a60 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/NotificationService.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using BitFinance.API.Models; +using BitFinance.API.Models.Response; +using BitFinance.API.Services.Interfaces; +using BitFinance.Business.Entities; +using BitFinance.Business.Enums; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace BitFinance.API.Services; + +public sealed class NotificationService(ApplicationDbContext dbContext) : INotificationService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task EnqueueAsync( + Guid organizationId, + NotificationType type, + string? aggregateId, + string deduplicationKey, + NotificationEventPayload payload, + CancellationToken cancellationToken = default) + { + if (await dbContext.NotificationOutboxMessages.AnyAsync( + message => message.DeduplicationKey == deduplicationKey, cancellationToken)) + return; + + var outboxMessage = new NotificationOutboxMessage + { + OrganizationId = organizationId, + Type = type, + AggregateId = aggregateId, + DeduplicationKey = deduplicationKey, + PayloadJson = JsonSerializer.Serialize(payload, JsonOptions), + }; + dbContext.NotificationOutboxMessages.Add(outboxMessage); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when ( + exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + dbContext.Entry(outboxMessage).State = EntityState.Detached; + } + } + + public async Task> GetAsync( + Guid organizationId, + string userId, + int page, + int pageSize, + bool unreadOnly, + CancellationToken cancellationToken = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); + var query = dbContext.Notifications.AsNoTracking() + .Where(notification => notification.OrganizationId == organizationId + && notification.RecipientUserId == userId); + + if (unreadOnly) + query = query.Where(notification => notification.ReadAt == null); + + var totalRecords = await query.CountAsync(cancellationToken); + var notifications = await query + .OrderByDescending(notification => notification.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var data = notifications.Select(notification => new NotificationResponse( + notification.Id, + notification.Type.ToString(), + JsonSerializer.Deserialize(notification.PayloadJson, JsonOptions), + notification.ActionPath, + notification.CreatedAt, + notification.ReadAt)).ToList(); + + return new PagedResponse( + data, + page, + pageSize, + totalRecords, + (int)Math.Ceiling(totalRecords / (double)pageSize)); + } + + public Task GetUnreadCountAsync(Guid organizationId, string userId, CancellationToken cancellationToken = default) => + dbContext.Notifications.CountAsync(notification => + notification.OrganizationId == organizationId + && notification.RecipientUserId == userId + && notification.ReadAt == null, cancellationToken); + + public async Task MarkReadAsync( + Guid organizationId, + string userId, + Guid notificationId, + CancellationToken cancellationToken = default) + { + var notification = await dbContext.Notifications.FirstOrDefaultAsync(item => + item.Id == notificationId + && item.OrganizationId == organizationId + && item.RecipientUserId == userId, cancellationToken); + if (notification is null) return false; + + notification.ReadAt ??= DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task MarkAllReadAsync(Guid organizationId, string userId, CancellationToken cancellationToken = default) + { + await dbContext.Notifications + .Where(notification => notification.OrganizationId == organizationId + && notification.RecipientUserId == userId + && notification.ReadAt == null) + .ExecuteUpdateAsync(updates => updates.SetProperty( + notification => notification.ReadAt, + _ => DateTime.UtcNow), cancellationToken); + } + + public async Task GetPreferencesAsync( + Guid organizationId, + string userId, + CancellationToken cancellationToken = default) + { + var preference = await dbContext.NotificationPreferences.AsNoTracking() + .FirstOrDefaultAsync(item => item.OrganizationId == organizationId && item.UserId == userId, cancellationToken); + var organization = await dbContext.Organizations.AsNoTracking() + .FirstAsync(item => item.Id == organizationId, cancellationToken); + + return new NotificationPreferenceResponse( + preference?.EmailBillRemindersEnabled ?? true, + PlanEntitlement.For(organization.EffectivePlanTier).HasEmailNotifications); + } + + public async Task UpdatePreferencesAsync( + Guid organizationId, + string userId, + bool enabled, + CancellationToken cancellationToken = default) + { + var preference = await dbContext.NotificationPreferences.FirstOrDefaultAsync(item => + item.OrganizationId == organizationId && item.UserId == userId, cancellationToken); + + if (preference is null) + { + preference = new NotificationPreference + { + OrganizationId = organizationId, + UserId = userId, + EmailBillRemindersEnabled = enabled, + }; + dbContext.NotificationPreferences.Add(preference); + } + else + { + preference.EmailBillRemindersEnabled = enabled; + preference.UpdatedAt = DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return await GetPreferencesAsync(organizationId, userId, cancellationToken); + } +} diff --git a/apps/backend/src/BitFinance.API/Services/OrganizationsService.cs b/apps/backend/src/BitFinance.API/Services/OrganizationsService.cs index e5ce0bd..c4e1641 100644 --- a/apps/backend/src/BitFinance.API/Services/OrganizationsService.cs +++ b/apps/backend/src/BitFinance.API/Services/OrganizationsService.cs @@ -10,13 +10,19 @@ public class OrganizationsService : IOrganizationsService { private readonly IOrganizationsRepository _organizationsRepository; private readonly IBudgetsRepository _budgetsRepository; + private readonly INotificationService _notificationService; + private readonly ITransactionRunner _transactionRunner; public OrganizationsService( IOrganizationsRepository organizationsRepository, - IBudgetsRepository budgetsRepository) + IBudgetsRepository budgetsRepository, + INotificationService notificationService, + ITransactionRunner transactionRunner) { _organizationsRepository = organizationsRepository; _budgetsRepository = budgetsRepository; + _notificationService = notificationService; + _transactionRunner = transactionRunner; } public async Task> GetAllByUserIdAsync(string userId) @@ -109,8 +115,24 @@ public async Task UpdateMemberRoleAsync( } } - targetMember.Role = newRole; - await _organizationsRepository.UpdateAsync(organization); + var previousRole = targetMember.Role; + var eventId = Guid.CreateVersion7(); + await _transactionRunner.ExecuteAsync(async () => + { + targetMember.Role = newRole; + await _organizationsRepository.UpdateAsync(organization); + await _notificationService.EnqueueAsync( + organizationId, + NotificationType.MemberRoleChanged, + targetUserId, + $"membership:role:{eventId:N}", + new NotificationEventPayload( + MemberUserId: targetUserId, + MemberName: targetMember.User?.FullName ?? targetMember.User?.UserName ?? targetUserId, + ActorName: actingMember.User?.FullName ?? actingMember.User?.UserName ?? actingUserId, + PreviousRole: previousRole.ToString(), + NewRole: newRole.ToString())); + }); return UpdateMemberRoleResult.Succeeded(targetMember); } @@ -136,8 +158,7 @@ public async Task RemoveMemberAsync( return RemoveMemberResult.Failed(RemoveMemberError.CannotRemoveLastOwner, "The last owner cannot leave the organization"); } - organization.Members.Remove(targetMember); - await _organizationsRepository.UpdateAsync(organization); + await RemoveMemberAndNotifyAsync(organization, targetMember, actingMember: targetMember); return RemoveMemberResult.Succeeded(); } @@ -161,8 +182,31 @@ public async Task RemoveMemberAsync( return RemoveMemberResult.Failed(RemoveMemberError.NotAuthorized, "Only owners and admins can remove members"); } - organization.Members.Remove(targetMember); - await _organizationsRepository.UpdateAsync(organization); + await RemoveMemberAndNotifyAsync(organization, targetMember, actingMember); return RemoveMemberResult.Succeeded(); } + + private async Task RemoveMemberAndNotifyAsync( + Organization organization, + OrganizationMember targetMember, + OrganizationMember actingMember) + { + var eventId = Guid.CreateVersion7(); + var memberName = targetMember.User?.FullName ?? targetMember.User?.UserName ?? targetMember.UserId; + var actorName = actingMember.User?.FullName ?? actingMember.User?.UserName ?? actingMember.UserId; + await _transactionRunner.ExecuteAsync(async () => + { + organization.Members.Remove(targetMember); + await _organizationsRepository.UpdateAsync(organization); + await _notificationService.EnqueueAsync( + organization.Id, + NotificationType.MemberRemoved, + targetMember.UserId, + $"membership:removed:{eventId:N}", + new NotificationEventPayload( + MemberUserId: targetMember.UserId, + MemberName: memberName, + ActorName: actorName)); + }); + } } diff --git a/apps/backend/src/BitFinance.API/Services/PortugueseBillEmailRenderer.cs b/apps/backend/src/BitFinance.API/Services/PortugueseBillEmailRenderer.cs new file mode 100644 index 0000000..4c1b175 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/PortugueseBillEmailRenderer.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Net; +using BitFinance.API.Services.Interfaces; + +namespace BitFinance.API.Services; + +public static class PortugueseBillEmailRenderer +{ + private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("pt-BR"); + + public static RenderedEmail Render(BillReminderEmail message) + { + var reminder = message.ReminderType switch + { + "BillDueSoon" => "vence em 3 dias", + "BillDueToday" => "vence hoje", + "BillOverdue" => "está vencida", + _ => "precisa da sua atenção", + }; + var subject = $"{message.BillDescription} {reminder}"; + var amount = message.AmountDue.ToString("C", Culture); + var dueDate = message.DueDate.ToString("dd 'de' MMMM 'de' yyyy", Culture); + + var safeName = WebUtility.HtmlEncode(message.RecipientName); + var safeOrganization = WebUtility.HtmlEncode(message.OrganizationName); + var safeDescription = WebUtility.HtmlEncode(message.BillDescription); + var safeActionUrl = WebUtility.HtmlEncode(message.ActionUrl); + + var html = $$""" + + + +
+
+

BitFinance · {{safeOrganization}}

+

{{safeDescription}} {{reminder}}

+

Olá, {{safeName}}. Esta conta está no seu horizonte financeiro.

+
+ {{amount}} + Vencimento: {{dueDate}} +
+ Ver conta +
+
+ + + """; + var text = $"Olá, {message.RecipientName}. {message.BillDescription} {reminder}. Valor: {amount}. Vencimento: {dueDate}. Ver conta: {message.ActionUrl}"; + + return new RenderedEmail(subject, html, text); + } +} + +public sealed record RenderedEmail(string Subject, string Html, string Text); diff --git a/apps/backend/src/BitFinance.API/Services/ResendEmailSender.cs b/apps/backend/src/BitFinance.API/Services/ResendEmailSender.cs new file mode 100644 index 0000000..8c9ec62 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Services/ResendEmailSender.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using BitFinance.API.Services.Interfaces; +using BitFinance.API.Settings; +using Microsoft.Extensions.Options; + +namespace BitFinance.API.Services; + +public sealed class ResendEmailSender(HttpClient httpClient, IOptions options) : IEmailSender +{ + private readonly NotificationOptions _options = options.Value; + public bool IsConfigured => _options.EmailEnabled; + + public async Task SendBillReminderAsync( + BillReminderEmail message, + Guid idempotencyKey, + CancellationToken cancellationToken) + { + var rendered = PortugueseBillEmailRenderer.Render(message); + using var request = new HttpRequestMessage(HttpMethod.Post, "emails"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ResendApiToken); + request.Headers.Add("Idempotency-Key", idempotencyKey.ToString("N")); + request.Content = JsonContent.Create(new + { + from = _options.FromAddress, + to = new[] { message.RecipientEmail }, + subject = rendered.Subject, + html = rendered.Html, + text = rendered.Text, + }); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + return new EmailSendResult(false, Error: $"Resend returned HTTP {(int)response.StatusCode}."); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var providerMessageId = document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null; + + return string.IsNullOrWhiteSpace(providerMessageId) + ? new EmailSendResult(false, Error: "Resend response did not include a message ID.") + : new EmailSendResult(true, providerMessageId); + } +} diff --git a/apps/backend/src/BitFinance.API/Settings/NotificationOptions.cs b/apps/backend/src/BitFinance.API/Settings/NotificationOptions.cs new file mode 100644 index 0000000..1991ed5 --- /dev/null +++ b/apps/backend/src/BitFinance.API/Settings/NotificationOptions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Options; + +namespace BitFinance.API.Settings; + +public sealed class NotificationOptions +{ + public const string SectionName = "Notifications"; + + public bool EmailEnabled { get; init; } + public string? ResendApiToken { get; init; } + public string? ResendWebhookSecret { get; init; } + public string? FromAddress { get; init; } + public string FrontendBaseUrl { get; init; } = "http://localhost:5174"; +} + +public sealed class NotificationOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, NotificationOptions options) + { + if (!Uri.TryCreate(options.FrontendBaseUrl, UriKind.Absolute, out _)) + return ValidateOptionsResult.Fail("Notifications:FrontendBaseUrl must be an absolute URL."); + + if (!options.EmailEnabled) + return ValidateOptionsResult.Success; + + var missing = new List(); + if (string.IsNullOrWhiteSpace(options.ResendApiToken)) missing.Add("ResendApiToken"); + if (string.IsNullOrWhiteSpace(options.ResendWebhookSecret)) missing.Add("ResendWebhookSecret"); + if (string.IsNullOrWhiteSpace(options.FromAddress)) missing.Add("FromAddress"); + + return missing.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail($"Notifications email is enabled but these settings are missing: {string.Join(", ", missing)}."); + } +} diff --git a/apps/backend/src/BitFinance.API/appsettings.Development.json b/apps/backend/src/BitFinance.API/appsettings.Development.json index 476e278..88a5ba1 100644 --- a/apps/backend/src/BitFinance.API/appsettings.Development.json +++ b/apps/backend/src/BitFinance.API/appsettings.Development.json @@ -1,5 +1,9 @@ { "ApplicationName": "bitfinance-api", + "AppSettings": { + "CacheEnabled": false, + "LoggingEnabled": true + }, "ConnectionStrings": { "Database": "", "Cache": "" @@ -22,5 +26,24 @@ "Cors": { "AllowedOrigins": ["http://localhost:3000"] }, + "Storage": { + "BucketName": "", + "Region": "us-east-1", + "Prefix": "", + "ServiceUrl": "" + }, + "Notifications": { + "EmailEnabled": false, + "ResendApiToken": "", + "ResendWebhookSecret": "", + "FromAddress": "", + "FrontendBaseUrl": "http://localhost:5174" + }, + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/apps/backend/src/BitFinance.API/appsettings.Production.json b/apps/backend/src/BitFinance.API/appsettings.Production.json index e097cce..7df9b76 100644 --- a/apps/backend/src/BitFinance.API/appsettings.Production.json +++ b/apps/backend/src/BitFinance.API/appsettings.Production.json @@ -4,10 +4,38 @@ "Database": "", "Cache": "" }, + "Jwt": { + "Key": "", + "Issuer": "", + "Audience": "", + "ExpirationInMinutes": 120 + }, + "Cors": { + "AllowedOrigins": [] + }, "AppSettings": { "CacheEnabled": false, "LoggingEnabled": true }, + "Storage": { + "BucketName": "", + "Region": "us-east-1", + "Prefix": "", + "ServiceUrl": "" + }, + "Notifications": { + "EmailEnabled": false, + "ResendApiToken": "", + "ResendWebhookSecret": "", + "FromAddress": "", + "FrontendBaseUrl": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, "Serilog": { "MinimumLevel": { "Default": "Information", @@ -17,5 +45,8 @@ } } }, + "KeyVault": { + "Url": "" + }, "AllowedHosts": "*" } diff --git a/apps/backend/src/BitFinance.API/appsettings.json b/apps/backend/src/BitFinance.API/appsettings.json index 291ffad..465849e 100644 --- a/apps/backend/src/BitFinance.API/appsettings.json +++ b/apps/backend/src/BitFinance.API/appsettings.json @@ -23,6 +23,22 @@ "Prefix": "", "ServiceUrl": "" }, + "Notifications": { + "EmailEnabled": false, + "ResendApiToken": "", + "ResendWebhookSecret": "", + "FromAddress": "", + "FrontendBaseUrl": "http://localhost:5174" + }, + "KeyVault": { + "Url": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/apps/backend/src/BitFinance.Business/Entities/Notification.cs b/apps/backend/src/BitFinance.Business/Entities/Notification.cs new file mode 100644 index 0000000..3d46e1d --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Entities/Notification.cs @@ -0,0 +1,20 @@ +using BitFinance.Business.Enums; + +namespace BitFinance.Business.Entities; + +public class Notification +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid SourceEventId { get; set; } + public Guid OrganizationId { get; set; } + public required string RecipientUserId { get; set; } + public NotificationType Type { get; set; } + public required string PayloadJson { get; set; } + public required string ActionPath { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ReadAt { get; set; } + + public Organization Organization { get; set; } = null!; + public User RecipientUser { get; set; } = null!; + public ICollection Deliveries { get; set; } = new List(); +} diff --git a/apps/backend/src/BitFinance.Business/Entities/NotificationDelivery.cs b/apps/backend/src/BitFinance.Business/Entities/NotificationDelivery.cs new file mode 100644 index 0000000..583fa28 --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Entities/NotificationDelivery.cs @@ -0,0 +1,20 @@ +using BitFinance.Business.Enums; + +namespace BitFinance.Business.Entities; + +public class NotificationDelivery +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid NotificationId { get; set; } + public string Channel { get; set; } = "email"; + public NotificationDeliveryStatus Status { get; set; } = NotificationDeliveryStatus.Pending; + public int Attempts { get; set; } + public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow; + public DateTime? LockedUntil { get; set; } + public string? ProviderMessageId { get; set; } + public DateTime? ProviderEventAt { get; set; } + public DateTime? SentAt { get; set; } + public string? LastError { get; set; } + + public Notification Notification { get; set; } = null!; +} diff --git a/apps/backend/src/BitFinance.Business/Entities/NotificationOutboxMessage.cs b/apps/backend/src/BitFinance.Business/Entities/NotificationOutboxMessage.cs new file mode 100644 index 0000000..16a9e17 --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Entities/NotificationOutboxMessage.cs @@ -0,0 +1,19 @@ +using BitFinance.Business.Enums; + +namespace BitFinance.Business.Entities; + +public class NotificationOutboxMessage +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid OrganizationId { get; set; } + public NotificationType Type { get; set; } + public string? AggregateId { get; set; } + public required string DeduplicationKey { get; set; } + public required string PayloadJson { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ProcessedAt { get; set; } + public int Attempts { get; set; } + public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow; + public DateTime? LockedUntil { get; set; } + public string? LastError { get; set; } +} diff --git a/apps/backend/src/BitFinance.Business/Entities/NotificationPreference.cs b/apps/backend/src/BitFinance.Business/Entities/NotificationPreference.cs new file mode 100644 index 0000000..f8c5530 --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Entities/NotificationPreference.cs @@ -0,0 +1,13 @@ +namespace BitFinance.Business.Entities; + +public class NotificationPreference +{ + public required string UserId { get; set; } + public Guid OrganizationId { get; set; } + public bool EmailBillRemindersEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } + + public User User { get; set; } = null!; + public Organization Organization { get; set; } = null!; +} diff --git a/apps/backend/src/BitFinance.Business/Entities/ProviderWebhookReceipt.cs b/apps/backend/src/BitFinance.Business/Entities/ProviderWebhookReceipt.cs new file mode 100644 index 0000000..87288aa --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Entities/ProviderWebhookReceipt.cs @@ -0,0 +1,7 @@ +namespace BitFinance.Business.Entities; + +public class ProviderWebhookReceipt +{ + public required string ProviderEventId { get; set; } + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; +} diff --git a/apps/backend/src/BitFinance.Business/Enums/NotificationDeliveryStatus.cs b/apps/backend/src/BitFinance.Business/Enums/NotificationDeliveryStatus.cs new file mode 100644 index 0000000..f7db347 --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Enums/NotificationDeliveryStatus.cs @@ -0,0 +1,12 @@ +namespace BitFinance.Business.Enums; + +public enum NotificationDeliveryStatus +{ + Pending = 1, + Processing = 2, + Sent = 3, + Delivered = 4, + Bounced = 5, + Failed = 6, + Suppressed = 7, +} diff --git a/apps/backend/src/BitFinance.Business/Enums/NotificationType.cs b/apps/backend/src/BitFinance.Business/Enums/NotificationType.cs new file mode 100644 index 0000000..0c8fd7b --- /dev/null +++ b/apps/backend/src/BitFinance.Business/Enums/NotificationType.cs @@ -0,0 +1,11 @@ +namespace BitFinance.Business.Enums; + +public enum NotificationType +{ + BillDueSoon = 1, + BillDueToday = 2, + BillOverdue = 3, + MemberJoined = 4, + MemberRoleChanged = 5, + MemberRemoved = 6, +} diff --git a/apps/backend/src/BitFinance.Data/Configurations/NotificationConfiguration.cs b/apps/backend/src/BitFinance.Data/Configurations/NotificationConfiguration.cs new file mode 100644 index 0000000..f305314 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Configurations/NotificationConfiguration.cs @@ -0,0 +1,30 @@ +using BitFinance.Business.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BitFinance.Data.Configurations; + +public class NotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(notification => notification.Id); + builder.Property(notification => notification.Type).HasConversion().HasMaxLength(64).IsRequired(); + builder.Property(notification => notification.RecipientUserId).IsRequired(); + builder.Property(notification => notification.PayloadJson).HasColumnType("jsonb").IsRequired(); + builder.Property(notification => notification.ActionPath).HasMaxLength(500).IsRequired(); + builder.Property(notification => notification.CreatedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(notification => notification.ReadAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.HasIndex(notification => new { notification.SourceEventId, notification.RecipientUserId }).IsUnique(); + builder.HasIndex(notification => new { notification.OrganizationId, notification.RecipientUserId, notification.ReadAt, notification.CreatedAt }); + builder.HasOne(notification => notification.Organization) + .WithMany() + .HasForeignKey(notification => notification.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne(notification => notification.RecipientUser) + .WithMany() + .HasForeignKey(notification => notification.RecipientUserId) + .OnDelete(DeleteBehavior.Cascade); + builder.ToTable("notifications"); + } +} diff --git a/apps/backend/src/BitFinance.Data/Configurations/NotificationDeliveryConfiguration.cs b/apps/backend/src/BitFinance.Data/Configurations/NotificationDeliveryConfiguration.cs new file mode 100644 index 0000000..2b41ed7 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Configurations/NotificationDeliveryConfiguration.cs @@ -0,0 +1,29 @@ +using BitFinance.Business.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BitFinance.Data.Configurations; + +public class NotificationDeliveryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(delivery => delivery.Id); + builder.Property(delivery => delivery.Channel).HasMaxLength(32).IsRequired(); + builder.Property(delivery => delivery.Status).HasConversion().HasMaxLength(32).IsRequired(); + builder.Property(delivery => delivery.NextAttemptAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(delivery => delivery.LockedUntil).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(delivery => delivery.ProviderMessageId).HasMaxLength(256); + builder.Property(delivery => delivery.ProviderEventAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(delivery => delivery.SentAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(delivery => delivery.LastError).HasMaxLength(2000); + builder.HasIndex(delivery => new { delivery.NotificationId, delivery.Channel }).IsUnique(); + builder.HasIndex(delivery => delivery.ProviderMessageId); + builder.HasIndex(delivery => new { delivery.Status, delivery.NextAttemptAt, delivery.LockedUntil }); + builder.HasOne(delivery => delivery.Notification) + .WithMany(notification => notification.Deliveries) + .HasForeignKey(delivery => delivery.NotificationId) + .OnDelete(DeleteBehavior.Cascade); + builder.ToTable("notification_deliveries"); + } +} diff --git a/apps/backend/src/BitFinance.Data/Configurations/NotificationOutboxMessageConfiguration.cs b/apps/backend/src/BitFinance.Data/Configurations/NotificationOutboxMessageConfiguration.cs new file mode 100644 index 0000000..4e8ef05 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Configurations/NotificationOutboxMessageConfiguration.cs @@ -0,0 +1,25 @@ +using BitFinance.Business.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BitFinance.Data.Configurations; + +public class NotificationOutboxMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(message => message.Id); + builder.Property(message => message.Type).HasConversion().HasMaxLength(64).IsRequired(); + builder.Property(message => message.AggregateId).HasMaxLength(128); + builder.Property(message => message.DeduplicationKey).HasMaxLength(256).IsRequired(); + builder.Property(message => message.PayloadJson).HasColumnType("jsonb").IsRequired(); + builder.Property(message => message.CreatedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(message => message.ProcessedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(message => message.NextAttemptAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(message => message.LockedUntil).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(message => message.LastError).HasMaxLength(2000); + builder.HasIndex(message => message.DeduplicationKey).IsUnique(); + builder.HasIndex(message => new { message.ProcessedAt, message.NextAttemptAt, message.LockedUntil }); + builder.ToTable("notification_outbox_messages"); + } +} diff --git a/apps/backend/src/BitFinance.Data/Configurations/NotificationPreferenceConfiguration.cs b/apps/backend/src/BitFinance.Data/Configurations/NotificationPreferenceConfiguration.cs new file mode 100644 index 0000000..5c33455 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Configurations/NotificationPreferenceConfiguration.cs @@ -0,0 +1,25 @@ +using BitFinance.Business.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BitFinance.Data.Configurations; + +public class NotificationPreferenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(preference => new { preference.UserId, preference.OrganizationId }); + builder.Property(preference => preference.EmailBillRemindersEnabled).HasDefaultValue(true); + builder.Property(preference => preference.CreatedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.Property(preference => preference.UpdatedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.HasOne(preference => preference.User) + .WithMany() + .HasForeignKey(preference => preference.UserId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne(preference => preference.Organization) + .WithMany() + .HasForeignKey(preference => preference.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + builder.ToTable("notification_preferences"); + } +} diff --git a/apps/backend/src/BitFinance.Data/Configurations/ProviderWebhookReceiptConfiguration.cs b/apps/backend/src/BitFinance.Data/Configurations/ProviderWebhookReceiptConfiguration.cs new file mode 100644 index 0000000..2abe0fa --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Configurations/ProviderWebhookReceiptConfiguration.cs @@ -0,0 +1,16 @@ +using BitFinance.Business.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BitFinance.Data.Configurations; + +public class ProviderWebhookReceiptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(receipt => receipt.ProviderEventId); + builder.Property(receipt => receipt.ProviderEventId).HasMaxLength(256); + builder.Property(receipt => receipt.ReceivedAt).HasColumnType("timestamp with time zone").HasPrecision(3); + builder.ToTable("provider_webhook_receipts"); + } +} diff --git a/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContext.cs b/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContext.cs index eb36bdc..185bd65 100644 --- a/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContext.cs +++ b/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContext.cs @@ -19,6 +19,11 @@ public class ApplicationDbContext(DbContextOptions options public DbSet Attachments => Set(); public DbSet UserSettings => Set(); public DbSet RefreshTokens => Set(); + public DbSet NotificationOutboxMessages => Set(); + public DbSet Notifications => Set(); + public DbSet NotificationDeliveries => Set(); + public DbSet NotificationPreferences => Set(); + public DbSet ProviderWebhookReceipts => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContextFactory.cs b/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContextFactory.cs new file mode 100644 index 0000000..e20d59a --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Contexts/ApplicationDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace BitFinance.Data.Contexts; + +public sealed class ApplicationDbContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Database") + ?? "Host=localhost;Port=5432;Database=bitfinance;Username=postgres;Password=postgres"; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + return new ApplicationDbContext(options); + } +} diff --git a/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.Designer.cs b/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.Designer.cs new file mode 100644 index 0000000..91fec56 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.Designer.cs @@ -0,0 +1,1446 @@ +// +using System; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BitFinance.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715040314_AddNotifications")] + partial class AddNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BitFinance.Business.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttachmentType") + .HasColumnType("integer") + .HasColumnName("attachment_type"); + + b.Property("BillId") + .HasColumnType("uuid") + .HasColumnName("bill_id"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExpenseId") + .HasColumnType("uuid") + .HasColumnName("expense_id"); + + b.Property("FileCategory") + .HasColumnType("integer") + .HasColumnName("file_category"); + + b.Property("FileHash") + .HasColumnType("text") + .HasColumnName("file_hash"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("FileSizeInBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_in_bytes"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("original_file_name"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("storage_path"); + + b.Property("UploadedAt") + .HasColumnType("timestamptz") + .HasColumnName("uploaded_at"); + + b.Property("UploadedByUserId") + .HasColumnType("text") + .HasColumnName("uploaded_by_user_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_attachments"); + + b.HasIndex("BillId") + .HasDatabaseName("ix_attachments_bill_id"); + + b.HasIndex("ExpenseId") + .HasDatabaseName("ix_attachments_expense_id"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_attachments_organization_id"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_attachments_user_id"); + + b.ToTable("attachments", null, t => + { + t.HasCheckConstraint("ck_attachments_single_owner", "(CASE WHEN bill_id IS NOT NULL THEN 1 ELSE 0 END +\n CASE WHEN expense_id IS NOT NULL THEN 1 ELSE 0 END +\n CASE WHEN user_id IS NOT NULL THEN 1 ELSE 0 END) = 1"); + }); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Bill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AmountDue") + .HasColumnType("numeric(10,2)") + .HasColumnName("amount_due"); + + b.Property("AmountPaid") + .HasColumnType("numeric(10,2)") + .HasColumnName("amount_paid"); + + b.Property("BillSeriesId") + .HasColumnType("uuid") + .HasColumnName("bill_series_id"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("OccurrenceNumber") + .HasColumnType("integer") + .HasColumnName("occurrence_number"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("PaymentDate") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("payment_date"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TotalOccurrences") + .HasColumnType("integer") + .HasColumnName("total_occurrences"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bills"); + + b.HasIndex("BillSeriesId") + .HasDatabaseName("ix_bills_bill_series_id"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_bills_organization_id"); + + b.ToTable("bills", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.BillSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AmountDue") + .HasColumnType("numeric(10,2)") + .HasColumnName("amount_due"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text") + .HasColumnName("frequency"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("NextOccurrenceNumber") + .HasColumnType("integer") + .HasColumnName("next_occurrence_number"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("StoppedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("stopped_at"); + + b.Property("TotalOccurrences") + .HasColumnType("integer") + .HasColumnName("total_occurrences"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bill_series"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_bill_series_organization_id"); + + b.ToTable("bill_series", null, t => + { + t.HasCheckConstraint("ck_bill_series_amount_non_negative", "amount_due >= 0"); + + t.HasCheckConstraint("ck_bill_series_next_occurrence_positive", "next_occurrence_number > 0"); + }); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("numeric(10,2)") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_budgets"); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasDatabaseName("ix_budgets_organization_id"); + + b.ToTable("budgets", null, t => + { + t.HasCheckConstraint("ck_budgets_amount_non_negative", "amount >= 0"); + }); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("numeric(10,2)") + .HasColumnName("amount"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_user_id"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_expenses"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_expenses_created_by_user_id"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_expenses_organization_id"); + + b.ToTable("expenses", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedByUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by_user_id"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token_hash"); + + b.HasKey("Id") + .HasName("pk_invitations"); + + b.HasIndex("InvitedByUserId"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_invitations_organization_id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("invitations", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("action_path"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ReadAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("RecipientUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("recipient_user_id"); + + b.Property("SourceEventId") + .HasColumnType("uuid") + .HasColumnName("source_event_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("RecipientUserId") + .HasDatabaseName("ix_notifications_recipient_user_id"); + + b.HasIndex("SourceEventId", "RecipientUserId") + .IsUnique(); + + b.HasIndex("OrganizationId", "RecipientUserId", "ReadAt", "CreatedAt"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("channel"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LockedUntil") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NextAttemptAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("NotificationId") + .HasColumnType("uuid") + .HasColumnName("notification_id"); + + b.Property("ProviderEventAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("provider_event_at"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("provider_message_id"); + + b.Property("SentAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_notification_deliveries"); + + b.HasIndex("ProviderMessageId"); + + b.HasIndex("NotificationId", "Channel") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LockedUntil"); + + b.ToTable("notification_deliveries", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("deduplication_key"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LockedUntil") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NextAttemptAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notification_outbox_messages"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("ProcessedAt", "NextAttemptAt", "LockedUntil"); + + b.ToTable("notification_outbox_messages", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationPreference", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EmailBillRemindersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("email_bill_reminders_enabled"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("UserId", "OrganizationId"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_notification_preferences_organization_id"); + + b.ToTable("notification_preferences", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("PlanExpiresAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("plan_expires_at"); + + b.Property("PlanTier") + .HasColumnType("integer") + .HasColumnName("plan_tier"); + + b.Property("TimeZoneId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasDefaultValue("America/Sao_Paulo") + .HasColumnName("timezone_id"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_organizations"); + + b.ToTable("organizations", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.OrganizationMember", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("JoinedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("UserId", "OrganizationId"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_organization_members_organization_id"); + + b.ToTable("organization_members", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.ProviderWebhookReceipt", b => + { + b.Property("ProviderEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("provider_event_id"); + + b.Property("ReceivedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.HasKey("ProviderEventId"); + + b.ToTable("provider_webhook_receipts", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("created_by_ip"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("revoked_reason"); + + b.Property("TokenFamilyId") + .HasColumnType("uuid") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("ReplacedByTokenId") + .HasDatabaseName("ix_refresh_tokens_replaced_by_token_id"); + + b.HasIndex("TokenFamilyId"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.HasIndex("UserId", "IsRevoked", "ExpiresAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.User", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("email_index"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("user_name_index"); + + b.ToTable("asp_net_users", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.UserSettings", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PreferredLanguage") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("preferred_language"); + + b.Property("TimeZoneId") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("timezone_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("UserId"); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("role_name_index"); + + b.ToTable("asp_net_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("asp_net_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("asp_net_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("asp_net_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("asp_net_user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("asp_net_user_tokens", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Attachment", b => + { + b.HasOne("BitFinance.Business.Entities.Bill", "Bill") + .WithMany("Attachments") + .HasForeignKey("BillId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_attachments_bills_bill_id"); + + b.HasOne("BitFinance.Business.Entities.Expense", "Expense") + .WithMany("Attachments") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_attachments_expenses_expense_id"); + + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_attachments_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithOne("Avatar") + .HasForeignKey("BitFinance.Business.Entities.Attachment", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_attachments_asp_net_users_user_id"); + + b.Navigation("Bill"); + + b.Navigation("Expense"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Bill", b => + { + b.HasOne("BitFinance.Business.Entities.BillSeries", "BillSeries") + .WithMany("Bills") + .HasForeignKey("BillSeriesId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bills_bill_series_bill_series_id"); + + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany("Bills") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bills_organizations_organization_id"); + + b.Navigation("BillSeries"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.BillSeries", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bill_series_organizations_organization_id"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Budget", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithOne("Budget") + .HasForeignKey("BitFinance.Business.Entities.Budget", "OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_budgets_organizations_organization_id"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Expense", b => + { + b.HasOne("BitFinance.Business.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_expenses_asp_net_users_created_by_user_id"); + + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany("Expenses") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_expenses_organizations_organization_id"); + + b.Navigation("CreatedByUser"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Invitation", b => + { + b.HasOne("BitFinance.Business.Entities.User", "InvitedBy") + .WithMany() + .HasForeignKey("InvitedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired() + .HasConstraintName("fk_invitations_asp_net_users_invited_by_id"); + + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany("Invitations") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_invitations_organizations_organization_id"); + + b.Navigation("InvitedBy"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_asp_net_users_recipient_user_id"); + + b.Navigation("Organization"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationDelivery", b => + { + b.HasOne("BitFinance.Business.Entities.Notification", "Notification") + .WithMany("Deliveries") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_deliveries_notifications_notification_id"); + + b.Navigation("Notification"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationPreference", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_preferences_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_preferences_asp_net_users_user_id"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.OrganizationMember", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_organization_members_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithMany("OrganizationMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_organization_members_asp_net_users_user_id"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.RefreshToken", b => + { + b.HasOne("BitFinance.Business.Entities.RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_refresh_tokens_refresh_tokens_replaced_by_token_id"); + + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.UserSettings", b => + { + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("BitFinance.Business.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_settings_asp_net_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BitFinance.Business.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BitFinance.Business.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("BitFinance.Business.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BitFinance.Business.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Bill", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.BillSeries", b => + { + b.Navigation("Bills"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Expense", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.Navigation("Deliveries"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.Organization", b => + { + b.Navigation("Bills"); + + b.Navigation("Budget"); + + b.Navigation("Expenses"); + + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.User", b => + { + b.Navigation("Avatar"); + + b.Navigation("OrganizationMemberships"); + + b.Navigation("Settings") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.cs b/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.cs new file mode 100644 index 0000000..6dff435 --- /dev/null +++ b/apps/backend/src/BitFinance.Data/Migrations/20260715040314_AddNotifications.cs @@ -0,0 +1,201 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BitFinance.Data.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "notification_outbox_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + organization_id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + aggregate_id = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + deduplication_key = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + payload_json = table.Column(type: "jsonb", nullable: false), + created_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false), + processed_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true), + attempts = table.Column(type: "integer", nullable: false), + next_attempt_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false), + locked_until = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true), + last_error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_notification_outbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "notification_preferences", + columns: table => new + { + user_id = table.Column(type: "text", nullable: false), + organization_id = table.Column(type: "uuid", nullable: false), + email_bill_reminders_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + created_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false), + updated_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_notification_preferences", x => new { x.user_id, x.organization_id }); + table.ForeignKey( + name: "fk_notification_preferences_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_notification_preferences_organizations_organization_id", + column: x => x.organization_id, + principalTable: "organizations", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "notifications", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + source_event_id = table.Column(type: "uuid", nullable: false), + organization_id = table.Column(type: "uuid", nullable: false), + recipient_user_id = table.Column(type: "text", nullable: false), + type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + payload_json = table.Column(type: "jsonb", nullable: false), + action_path = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + created_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false), + read_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_notifications", x => x.id); + table.ForeignKey( + name: "fk_notifications_asp_net_users_recipient_user_id", + column: x => x.recipient_user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_notifications_organizations_organization_id", + column: x => x.organization_id, + principalTable: "organizations", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "provider_webhook_receipts", + columns: table => new + { + provider_event_id = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + received_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_provider_webhook_receipts", x => x.provider_event_id); + }); + + migrationBuilder.CreateTable( + name: "notification_deliveries", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + notification_id = table.Column(type: "uuid", nullable: false), + channel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + attempts = table.Column(type: "integer", nullable: false), + next_attempt_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: false), + locked_until = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true), + provider_message_id = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + provider_event_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true), + sent_at = table.Column(type: "timestamp(3) with time zone", precision: 3, nullable: true), + last_error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_notification_deliveries", x => x.id); + table.ForeignKey( + name: "fk_notification_deliveries_notifications_notification_id", + column: x => x.notification_id, + principalTable: "notifications", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_notification_deliveries_notification_id_channel", + table: "notification_deliveries", + columns: new[] { "notification_id", "channel" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_notification_deliveries_provider_message_id", + table: "notification_deliveries", + column: "provider_message_id"); + + migrationBuilder.CreateIndex( + name: "IX_notification_deliveries_status_next_attempt_at_locked_until", + table: "notification_deliveries", + columns: new[] { "status", "next_attempt_at", "locked_until" }); + + migrationBuilder.CreateIndex( + name: "IX_notification_outbox_messages_deduplication_key", + table: "notification_outbox_messages", + column: "deduplication_key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_notification_outbox_messages_processed_at_next_attempt_at_l~", + table: "notification_outbox_messages", + columns: new[] { "processed_at", "next_attempt_at", "locked_until" }); + + migrationBuilder.CreateIndex( + name: "ix_notification_preferences_organization_id", + table: "notification_preferences", + column: "organization_id"); + + migrationBuilder.CreateIndex( + name: "IX_notifications_organization_id_recipient_user_id_read_at_cre~", + table: "notifications", + columns: new[] { "organization_id", "recipient_user_id", "read_at", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_notifications_recipient_user_id", + table: "notifications", + column: "recipient_user_id"); + + migrationBuilder.CreateIndex( + name: "IX_notifications_source_event_id_recipient_user_id", + table: "notifications", + columns: new[] { "source_event_id", "recipient_user_id" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "notification_deliveries"); + + migrationBuilder.DropTable( + name: "notification_outbox_messages"); + + migrationBuilder.DropTable( + name: "notification_preferences"); + + migrationBuilder.DropTable( + name: "provider_webhook_receipts"); + + migrationBuilder.DropTable( + name: "notifications"); + } + } +} diff --git a/apps/backend/src/BitFinance.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/apps/backend/src/BitFinance.Data/Migrations/ApplicationDbContextModelSnapshot.cs index fd9a3dd..05dea9d 100644 --- a/apps/backend/src/BitFinance.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/apps/backend/src/BitFinance.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -425,6 +425,244 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("invitations", (string)null); }); + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("action_path"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ReadAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("RecipientUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("recipient_user_id"); + + b.Property("SourceEventId") + .HasColumnType("uuid") + .HasColumnName("source_event_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("RecipientUserId") + .HasDatabaseName("ix_notifications_recipient_user_id"); + + b.HasIndex("SourceEventId", "RecipientUserId") + .IsUnique(); + + b.HasIndex("OrganizationId", "RecipientUserId", "ReadAt", "CreatedAt"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("channel"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LockedUntil") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NextAttemptAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("NotificationId") + .HasColumnType("uuid") + .HasColumnName("notification_id"); + + b.Property("ProviderEventAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("provider_event_at"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("provider_message_id"); + + b.Property("SentAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_notification_deliveries"); + + b.HasIndex("ProviderMessageId"); + + b.HasIndex("NotificationId", "Channel") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LockedUntil"); + + b.ToTable("notification_deliveries", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("deduplication_key"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LockedUntil") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NextAttemptAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notification_outbox_messages"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("ProcessedAt", "NextAttemptAt", "LockedUntil"); + + b.ToTable("notification_outbox_messages", (string)null); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationPreference", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("CreatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EmailBillRemindersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("email_bill_reminders_enabled"); + + b.Property("UpdatedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("UserId", "OrganizationId"); + + b.HasIndex("OrganizationId") + .HasDatabaseName("ix_notification_preferences_organization_id"); + + b.ToTable("notification_preferences", (string)null); + }); + modelBuilder.Entity("BitFinance.Business.Entities.Organization", b => { b.Property("Id") @@ -497,6 +735,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("organization_members", (string)null); }); + modelBuilder.Entity("BitFinance.Business.Entities.ProviderWebhookReceipt", b => + { + b.Property("ProviderEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("provider_event_id"); + + b.Property("ReceivedAt") + .HasPrecision(3) + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.HasKey("ProviderEventId"); + + b.ToTable("provider_webhook_receipts", (string)null); + }); + modelBuilder.Entity("BitFinance.Business.Entities.RefreshToken", b => { b.Property("Id") @@ -976,6 +1231,60 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_asp_net_users_recipient_user_id"); + + b.Navigation("Organization"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationDelivery", b => + { + b.HasOne("BitFinance.Business.Entities.Notification", "Notification") + .WithMany("Deliveries") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_deliveries_notifications_notification_id"); + + b.Navigation("Notification"); + }); + + modelBuilder.Entity("BitFinance.Business.Entities.NotificationPreference", b => + { + b.HasOne("BitFinance.Business.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_preferences_organizations_organization_id"); + + b.HasOne("BitFinance.Business.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_preferences_asp_net_users_user_id"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("BitFinance.Business.Entities.OrganizationMember", b => { b.HasOne("BitFinance.Business.Entities.Organization", "Organization") @@ -1101,6 +1410,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Attachments"); }); + modelBuilder.Entity("BitFinance.Business.Entities.Notification", b => + { + b.Navigation("Deliveries"); + }); + modelBuilder.Entity("BitFinance.Business.Entities.Organization", b => { b.Navigation("Bills"); diff --git a/apps/backend/tests/BitFinance.API.UnitTests/InvitationsServiceTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/InvitationsServiceTests.cs index 93846d9..2e106d8 100644 --- a/apps/backend/tests/BitFinance.API.UnitTests/InvitationsServiceTests.cs +++ b/apps/backend/tests/BitFinance.API.UnitTests/InvitationsServiceTests.cs @@ -1,5 +1,6 @@ using BitFinance.API.Models; using BitFinance.API.Services; +using BitFinance.API.Services.Interfaces; using BitFinance.Business.Entities; using BitFinance.Business.Enums; using BitFinance.Data.Repositories.Interfaces; @@ -12,15 +13,24 @@ public class InvitationsServiceTests { private readonly Mock _invitationsRepositoryMock; private readonly Mock _organizationsRepositoryMock; + private readonly Mock _notificationServiceMock; + private readonly Mock _transactionRunnerMock; private readonly InvitationsService _sut; public InvitationsServiceTests() { _invitationsRepositoryMock = new Mock(); _organizationsRepositoryMock = new Mock(); + _notificationServiceMock = new Mock(); + _transactionRunnerMock = new Mock(); + _transactionRunnerMock + .Setup(runner => runner.ExecuteAsync(It.IsAny>(), It.IsAny())) + .Returns((Func operation, CancellationToken _) => operation()); _sut = new InvitationsService( _invitationsRepositoryMock.Object, - _organizationsRepositoryMock.Object); + _organizationsRepositoryMock.Object, + _notificationServiceMock.Object, + _transactionRunnerMock.Object); } private Organization CreateOrganizationWithMembers(List<(string UserId, OrgRole Role)> members) @@ -121,4 +131,35 @@ public async Task CreateInvitation_AdminCanInviteMember_ShouldSucceed() Assert.True(result.Success); Assert.Equal(OrgRole.Member, result.Invitation?.Role); } + + [Fact] + public async Task JoinOrganization_ValidInvitation_ShouldEnqueueMemberJoined() + { + var organization = CreateOrganizationWithMembers([("owner1", OrgRole.Owner)]); + var invitation = new Invitation + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + Organization = organization, + Email = "new-member@test.com", + Role = OrgRole.Member, + Status = InvitationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours(1), + }; + _invitationsRepositoryMock + .Setup(repository => repository.GetByTokenHashAsync(It.IsAny())) + .ReturnsAsync(invitation); + + var result = await _sut.JoinOrganizationAsync("raw-token", "member1", "new-member@test.com"); + + Assert.True(result.Success); + Assert.Contains(organization.Members, member => member.UserId == "member1"); + _notificationServiceMock.Verify(service => service.EnqueueAsync( + organization.Id, + NotificationType.MemberJoined, + "member1", + $"membership:joined:{invitation.Id:N}", + It.Is(payload => payload.MemberUserId == "member1"), + It.IsAny()), Times.Once); + } } diff --git a/apps/backend/tests/BitFinance.API.UnitTests/NotificationPersistenceModelTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/NotificationPersistenceModelTests.cs new file mode 100644 index 0000000..2383fa8 --- /dev/null +++ b/apps/backend/tests/BitFinance.API.UnitTests/NotificationPersistenceModelTests.cs @@ -0,0 +1,32 @@ +using BitFinance.Business.Entities; +using BitFinance.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BitFinance.API.UnitTests; + +public sealed class NotificationPersistenceModelTests +{ + [Fact] + public void Model_ConfiguresDurableNotificationTablesAndDeduplicationIndexes() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=bitfinance_model;Username=postgres;Password=postgres") + .Options; + using var context = new ApplicationDbContext(options); + + Assert.Equal("notification_outbox_messages", context.Model.FindEntityType(typeof(NotificationOutboxMessage))?.GetTableName()); + Assert.Equal("notifications", context.Model.FindEntityType(typeof(Notification))?.GetTableName()); + Assert.Equal("notification_deliveries", context.Model.FindEntityType(typeof(NotificationDelivery))?.GetTableName()); + Assert.Equal("notification_preferences", context.Model.FindEntityType(typeof(NotificationPreference))?.GetTableName()); + + var outbox = context.Model.FindEntityType(typeof(NotificationOutboxMessage)); + Assert.Contains(outbox!.GetIndexes(), index => index.IsUnique + && index.Properties.Select(property => property.Name).SequenceEqual([nameof(NotificationOutboxMessage.DeduplicationKey)])); + + var notification = context.Model.FindEntityType(typeof(Notification)); + Assert.Contains(notification!.GetIndexes(), index => index.IsUnique + && index.Properties.Select(property => property.Name).SequenceEqual([ + nameof(Notification.SourceEventId), nameof(Notification.RecipientUserId)])); + } +} diff --git a/apps/backend/tests/BitFinance.API.UnitTests/NotificationRetryPolicyTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/NotificationRetryPolicyTests.cs new file mode 100644 index 0000000..518fe8c --- /dev/null +++ b/apps/backend/tests/BitFinance.API.UnitTests/NotificationRetryPolicyTests.cs @@ -0,0 +1,27 @@ +using BitFinance.API.Services; +using Xunit; + +namespace BitFinance.API.UnitTests; + +public class NotificationRetryPolicyTests +{ + [Theory] + [InlineData(1, 1)] + [InlineData(2, 5)] + [InlineData(3, 30)] + [InlineData(4, 120)] + public void GetNextAttemptAt_ReturnsConfiguredBackoff(int attempt, int minutes) + { + var now = new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc); + + var result = NotificationRetryPolicy.GetNextAttemptAt(attempt, now); + + Assert.Equal(now.AddMinutes(minutes), result); + } + + [Fact] + public void GetNextAttemptAt_StopsAfterFifthAttempt() + { + Assert.Null(NotificationRetryPolicy.GetNextAttemptAt(5, DateTime.UtcNow)); + } +} diff --git a/apps/backend/tests/BitFinance.API.UnitTests/NotificationRulesTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/NotificationRulesTests.cs new file mode 100644 index 0000000..6a5f5bd --- /dev/null +++ b/apps/backend/tests/BitFinance.API.UnitTests/NotificationRulesTests.cs @@ -0,0 +1,69 @@ +using BitFinance.API.Services; +using BitFinance.Business.Entities; +using BitFinance.Business.Enums; +using Xunit; + +namespace BitFinance.API.UnitTests; + +public class NotificationRulesTests +{ + [Theory] + [InlineData(3, BillStatus.Upcoming, NotificationType.BillDueSoon)] + [InlineData(0, BillStatus.Due, NotificationType.BillDueToday)] + [InlineData(-1, BillStatus.Overdue, NotificationType.BillOverdue)] + public void GetBillReminderType_ReturnsExpectedStage( + int daysFromToday, + BillStatus status, + NotificationType expected) + { + var today = new DateOnly(2026, 7, 15); + + var result = NotificationRules.GetBillReminderType(today.AddDays(daysFromToday), today, status); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(BillStatus.Paid)] + [InlineData(BillStatus.Cancelled)] + public void GetBillReminderType_DoesNotNotifyNonPayableBills(BillStatus status) + { + var today = new DateOnly(2026, 7, 15); + + var result = NotificationRules.GetBillReminderType(today, today, status); + + Assert.Null(result); + } + + [Fact] + public void GetMembershipRecipients_ReturnsOwnersAndAdminsOnly() + { + var members = new[] + { + new OrganizationMember { UserId = "owner", Role = OrgRole.Owner }, + new OrganizationMember { UserId = "admin", Role = OrgRole.Admin }, + new OrganizationMember { UserId = "member", Role = OrgRole.Member }, + }; + + var recipients = NotificationRules.GetMembershipRecipientIds(members); + + Assert.Equal(["admin", "owner"], recipients.OrderBy(value => value)); + } + + [Theory] + [InlineData(PlanTier.Free, true, true, false)] + [InlineData(PlanTier.Basic, false, true, false)] + [InlineData(PlanTier.Basic, true, false, false)] + [InlineData(PlanTier.Basic, true, true, true)] + [InlineData(PlanTier.Premium, true, true, true)] + public void CanSendBillEmail_EnforcesEntitlementPreferenceAndConfiguration( + PlanTier tier, + bool preferenceEnabled, + bool deliveryConfigured, + bool expected) + { + var result = NotificationRules.CanSendBillEmail(tier, preferenceEnabled, deliveryConfigured); + + Assert.Equal(expected, result); + } +} diff --git a/apps/backend/tests/BitFinance.API.UnitTests/OrganizationsServiceTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/OrganizationsServiceTests.cs index fb9ac94..332772c 100644 --- a/apps/backend/tests/BitFinance.API.UnitTests/OrganizationsServiceTests.cs +++ b/apps/backend/tests/BitFinance.API.UnitTests/OrganizationsServiceTests.cs @@ -1,5 +1,6 @@ using BitFinance.API.Models; using BitFinance.API.Services; +using BitFinance.API.Services.Interfaces; using BitFinance.Business.Entities; using BitFinance.Business.Enums; using BitFinance.Data.Repositories.Interfaces; @@ -12,13 +13,24 @@ public class OrganizationsServiceTests { private readonly Mock _orgRepoMock; private readonly Mock _budgetRepoMock; + private readonly Mock _notificationServiceMock; + private readonly Mock _transactionRunnerMock; private readonly OrganizationsService _sut; public OrganizationsServiceTests() { _orgRepoMock = new Mock(); _budgetRepoMock = new Mock(); - _sut = new OrganizationsService(_orgRepoMock.Object, _budgetRepoMock.Object); + _notificationServiceMock = new Mock(); + _transactionRunnerMock = new Mock(); + _transactionRunnerMock + .Setup(runner => runner.ExecuteAsync(It.IsAny>(), It.IsAny())) + .Returns((Func operation, CancellationToken _) => operation()); + _sut = new OrganizationsService( + _orgRepoMock.Object, + _budgetRepoMock.Object, + _notificationServiceMock.Object, + _transactionRunnerMock.Object); } private Organization CreateOrganizationWithMembers(List<(string UserId, OrgRole Role)> members) @@ -68,6 +80,13 @@ public async Task UpdateMemberRole_OwnerCanDemoteAdmin_ShouldSucceed() Assert.True(result.Success); Assert.Equal(OrgRole.Member, org.Members.Single(m => m.UserId == "admin1").Role); _orgRepoMock.Verify(r => r.UpdateAsync(org), Times.Once); + _notificationServiceMock.Verify(service => service.EnqueueAsync( + org.Id, + NotificationType.MemberRoleChanged, + "admin1", + It.Is(key => key.StartsWith("membership:role:")), + It.Is(payload => payload.PreviousRole == "Admin" && payload.NewRole == "Member"), + It.IsAny()), Times.Once); } [Fact] @@ -219,6 +238,13 @@ public async Task RemoveMember_OwnerCanRemoveAdmin_ShouldSucceed() Assert.Single(org.Members); Assert.DoesNotContain(org.Members, m => m.UserId == "admin1"); _orgRepoMock.Verify(r => r.UpdateAsync(org), Times.Once); + _notificationServiceMock.Verify(service => service.EnqueueAsync( + org.Id, + NotificationType.MemberRemoved, + "admin1", + It.Is(key => key.StartsWith("membership:removed:")), + It.Is(payload => payload.MemberUserId == "admin1"), + It.IsAny()), Times.Once); } [Fact] @@ -399,4 +425,4 @@ public async Task RemoveMember_OrganizationNotFound_ShouldFail() Assert.False(result.Success); Assert.Equal(RemoveMemberError.OrganizationNotFound, result.Error); } -} \ No newline at end of file +} diff --git a/apps/backend/tests/BitFinance.API.UnitTests/PortugueseBillEmailRendererTests.cs b/apps/backend/tests/BitFinance.API.UnitTests/PortugueseBillEmailRendererTests.cs new file mode 100644 index 0000000..7078c7f --- /dev/null +++ b/apps/backend/tests/BitFinance.API.UnitTests/PortugueseBillEmailRendererTests.cs @@ -0,0 +1,34 @@ +using BitFinance.API.Services; +using BitFinance.API.Services.Interfaces; +using Xunit; + +namespace BitFinance.API.UnitTests; + +public class PortugueseBillEmailRendererTests +{ + [Fact] + public void Render_FormatsPortugueseCopyAndEscapesUserContent() + { + var message = new BillReminderEmail( + "member@example.com", + "Ana", + "Casa & Família", + "Luz ", + 123.45m, + new DateOnly(2026, 7, 18), + "BillDueSoon", + "https://example.com/dashboard/bills/123"); + + var result = PortugueseBillEmailRenderer.Render(message); + + Assert.Contains("vence em 3 dias", result.Subject, StringComparison.OrdinalIgnoreCase); + Assert.Contains("R$", result.Html); + Assert.Contains("123,45", result.Html); + Assert.Contains("Casa &", result.Html); + Assert.DoesNotContain("Casa & Família", result.Html); + Assert.Contains("Luz <julho>", result.Html); + Assert.DoesNotContain("Luz ", result.Html); + Assert.Contains("https://example.com/dashboard/bills/123", result.Html); + Assert.Contains("Luz ", result.Text); + } +} diff --git a/apps/frontend-v2/docs/backend-endpoints.md b/apps/frontend-v2/docs/backend-endpoints.md deleted file mode 100644 index 2c42a6e..0000000 --- a/apps/frontend-v2/docs/backend-endpoints.md +++ /dev/null @@ -1,72 +0,0 @@ -# Backend endpoint coverage - -The frontend-v2 client is backed by typed Axios services and TanStack Query -consumers. All organization-scoped query keys include the selected -organization ID. `VITE_API_URL` points to `/api/v1`; health remains at the origin -root `/health`. - -| # | Method | Route | Client | Consumer | Status | -| ---: | --- | --- | --- | --- | --- | -| 1 | GET | `/health` | `healthService.getAsync` | API status badge | Client mapped | -| 2 | POST | `/identity/register` | `authService.registerAsync` | sign-up and session bootstrap | Client mapped | -| 3 | POST | `/identity/login` | `authService.loginAsync` | sign-in and return URL | Client mapped | -| 4 | POST | `/identity/refresh` | `authService.refreshAsync` | startup and single-flight retry | Client mapped | -| 5 | POST | `/identity/logout` | `authService.logoutAsync` | current-session sign-out | Client mapped | -| 6 | POST | `/identity/logout-all` | `authService.logoutAllAsync` | confirmed all-device sign-out | Client mapped | -| 7 | GET | `/identity/me` | `authService.getMeAsync` | canonical user/org bootstrap | Client mapped | -| 8 | POST | `/identity/manage/profile` | `accountService.updateProfileAsync` | account profile form | Client mapped | -| 9 | POST | `/identity/manage/avatar` | `accountService.uploadAvatarAsync` | validated avatar picker | Client mapped | -| 10 | DELETE | `/identity/manage/avatar` | `accountService.deleteAvatarAsync` | confirmed avatar removal | Client mapped | -| 11 | GET | `/organizations` | `organizationsService.listAsync` | organization switcher | Client mapped | -| 12 | GET | `/organizations/{organizationId}` | `organizationsService.getAsync` | organization settings/members | Client mapped | -| 13 | POST | `/organizations` | `organizationsService.createAsync` | onboarding | Client mapped | -| 14 | PATCH | `/organizations/{organizationId}` | `organizationsService.updateAsync` | workspace name | Client mapped | -| 15 | GET | `/organizations/{organizationId}/budget` | `organizationsService.getBudgetAsync` | nullable budget card | Client mapped | -| 16 | PUT | `/organizations/{organizationId}/budget` | `organizationsService.upsertBudgetAsync` | budget mutation | Client mapped | -| 17 | POST | `/organizations/{organizationId}/invite` | `organizationsService.createInviteAsync` | expiry/token join URL | Client mapped | -| 18 | POST | `/organizations/join?token=` | `organizationsService.joinAsync` | authenticated invite acceptance | Client mapped | -| 19 | GET | `/organizations/{organizationId}/dashboard/summary` | `dashboardService.getSummaryAsync` | KPI cards | Client mapped | -| 20 | GET | `/organizations/{organizationId}/dashboard/upcoming-bills` | `dashboardService.getUpcomingBillsAsync` | upcoming list/timeline | Client mapped | -| 21 | GET | `/organizations/{organizationId}/dashboard/recent-expenses` | `dashboardService.getRecentExpensesAsync` | recent spending/timeline | Client mapped | -| 22 | GET | `/organizations/{organizationId}/bills` | `billsService.listAsync` | server paging/search/status | Client mapped | -| 23 | POST | `/organizations/{organizationId}/bills` | `billsService.createAsync` | bill form | Client mapped | -| 24 | GET | `/organizations/{organizationId}/bills/{billId}` | `billsService.getAsync` | direct detail route | Client mapped | -| 25 | PATCH | `/organizations/{organizationId}/bills/{billId}` | `billsService.updateAsync` | edit/mark paid | Client mapped | -| 26 | DELETE | `/organizations/{organizationId}/bills/{billId}` | `billsService.deleteAsync` | confirmed deletion | Client mapped | -| 27 | POST | `/organizations/{organizationId}/bills/{billId}/documents` | `billsService.uploadDocumentAsync` | validated multipart upload | Client mapped | -| 28 | GET | `/organizations/{organizationId}/bills/{billId}/documents/{documentId}` | `billsService.getDocumentAsync` | authenticated preview | Client mapped | -| 29 | GET | `/organizations/{organizationId}/bills/{billId}/documents/{documentId}/download-url` | `billsService.getDocumentDownloadUrlAsync` | expiring download | Client mapped | -| 30 | DELETE | `/organizations/{organizationId}/bills/{billId}/documents/{documentId}` | `billsService.deleteDocumentAsync` | confirmed document removal | Client mapped | -| 31 | POST | `/organizations/{organizationId}/bills/series/{seriesId}/stop` | `billsService.stopSeriesAsync` | stop future occurrences | Client mapped | -| 32 | GET | `/organizations/{organizationId}/expenses` | `expensesService.listAsync` | server paging/date | Client mapped | -| 33 | POST | `/organizations/{organizationId}/expenses` | `expensesService.createAsync` | expense form with `me.id` | Client mapped | -| 34 | GET | `/organizations/{organizationId}/expenses/{expenseId}` | `expensesService.getAsync` | direct detail route | Client mapped | -| 35 | PATCH | `/organizations/{organizationId}/expenses/{expenseId}` | `expensesService.updateAsync` | edit form | Client mapped | -| 36 | DELETE | `/organizations/{organizationId}/expenses/{expenseId}` | `expensesService.deleteAsync` | confirmed deletion | Client mapped | -| 37 | POST | `/organizations/{organizationId}/expenses/{expenseId}/documents` | `expensesService.uploadDocumentAsync` | validated multipart upload | Client mapped | -| 38 | GET | `/organizations/{organizationId}/expenses/{expenseId}/documents/{attachmentId}` | `expensesService.getDocumentAsync` | authenticated blob download | Client mapped | -| 39 | DELETE | `/organizations/{organizationId}/expenses/{expenseId}/documents/{attachmentId}` | `expensesService.deleteDocumentAsync` | confirmed document removal | Client mapped | - -## Client boundaries - -- Access tokens live only in memory. Refresh tokens are handled by the backend's - HTTP-only cookie; JavaScript never writes either token to browser storage. -- The registration form enforces the backend's eight-character minimum password. -- Organization details expose only username/email for members. Roles, joined dates, - and timezones are intentionally not fabricated in the UI. -- Invitation roles use one explicit boundary: `Owner=1`, `Admin=2`, `Member=3`. - Invites show the returned expiry and URL but do not optimistically add a member. -- Budget `404` maps to `null` only for the budget service. Other `404` responses are - surfaced as errors. -- Bill search/status/date filters are sent to the server; series filtering is - limited to the currently loaded page. Expense search/status are explicitly local - filters over the loaded page because the backend accepts only page/date params. -- Bill Open uses the authenticated binary endpoint, while Download uses the signed - URL endpoint. Object URLs are revoked after use. Document uploads are limited to - 10 MiB and PDF/JPG/JPEG/PNG/DOC/DOCX; avatars are limited to 2 MiB and JPG/JPEG/PNG. -- Mutations invalidate feature list/detail data plus dashboard and auth dependencies - where the backend response can change them. - -The client mapping above is the source of truth for the 39 method/path rows, -representative query and JSON/multipart bodies, role mapping, encoded join tokens, -and nullable dashboard values. diff --git a/apps/frontend-v2/index.html b/apps/frontend-v2/index.html index 811a0ac..4957ad8 100644 --- a/apps/frontend-v2/index.html +++ b/apps/frontend-v2/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/frontend-v2/src/api/account/account.service.ts b/apps/frontend-v2/src/api/account/account.service.ts index b162b31..71313dd 100644 --- a/apps/frontend-v2/src/api/account/account.service.ts +++ b/apps/frontend-v2/src/api/account/account.service.ts @@ -5,16 +5,16 @@ import type { User } from "../auth/auth.types"; export const accountService = { async updateProfileAsync(firstName: string, lastName: string): Promise { try { return (await authApi.post("/identity/manage/profile", { firstName, lastName })).data; } - catch (error) { throw normalizeApiError(error, "Unable to update your profile."); } + catch (error) { throw normalizeApiError(error, "api.account.updateProfile"); } }, async uploadAvatarAsync(file: File) { try { const form = new FormData(); form.append("file", file); return (await authApi.post<{ id: string; fileName: string; contentType: string }>("/identity/manage/avatar", form)).data; - } catch (error) { throw normalizeApiError(error, "Unable to upload your avatar."); } + } catch (error) { throw normalizeApiError(error, "api.account.uploadAvatar"); } }, async deleteAvatarAsync() { try { await authApi.delete("/identity/manage/avatar"); } - catch (error) { throw normalizeApiError(error, "Unable to remove your avatar."); } + catch (error) { throw normalizeApiError(error, "api.account.removeAvatar"); } }, }; diff --git a/apps/frontend-v2/src/api/auth/auth.service.ts b/apps/frontend-v2/src/api/auth/auth.service.ts index 1805185..fe19da6 100644 --- a/apps/frontend-v2/src/api/auth/auth.service.ts +++ b/apps/frontend-v2/src/api/auth/auth.service.ts @@ -6,26 +6,26 @@ import { mapMeResponse } from "./auth.types"; export const authService = { async registerAsync(credentials: RegisterCredentials): Promise { try { return (await publicApi.post("/identity/register", credentials)).data; } - catch (error) { throw normalizeApiError(error, "Unable to create your account."); } + catch (error) { throw normalizeApiError(error, "api.auth.createAccount"); } }, async loginAsync(credentials: AuthCredentials): Promise { try { return (await publicApi.post("/identity/login", credentials)).data; } - catch (error) { throw normalizeApiError(error, "Unable to sign in."); } + catch (error) { throw normalizeApiError(error, "api.auth.signIn"); } }, async refreshAsync(): Promise { try { return (await publicApi.post("/identity/refresh")).data; } - catch (error) { throw normalizeApiError(error, "Unable to restore your session."); } + catch (error) { throw normalizeApiError(error, "api.auth.restoreSession"); } }, async logoutAsync() { try { await authApi.post("/identity/logout"); } - catch (error) { throw normalizeApiError(error, "Unable to sign out."); } + catch (error) { throw normalizeApiError(error, "api.auth.signOut"); } }, async logoutAllAsync() { try { await authApi.post("/identity/logout-all"); } - catch (error) { throw normalizeApiError(error, "Unable to sign out all sessions."); } + catch (error) { throw normalizeApiError(error, "api.auth.signOutAll"); } }, async getMeAsync(): Promise { try { return mapMeResponse((await authApi.get("/identity/me")).data); } - catch (error) { throw normalizeApiError(error, "Unable to load your account."); } + catch (error) { throw normalizeApiError(error, "api.auth.loadAccount"); } }, }; diff --git a/apps/frontend-v2/src/api/bills/bills.service.ts b/apps/frontend-v2/src/api/bills/bills.service.ts index cd8adef..4d10ab2 100644 --- a/apps/frontend-v2/src/api/bills/bills.service.ts +++ b/apps/frontend-v2/src/api/bills/bills.service.ts @@ -11,42 +11,42 @@ export const billsService = { try { const response = await authApi.get>(`/organizations/${filters.organizationId}/bills`, { params: { page: filters.page, pageSize: filters.pageSize, from: filters.from?.toISOString(), to: filters.to?.toISOString(), status: filters.status, description: filters.description || undefined } }); return { ...response.data, data: response.data.data.map(map) }; - } catch (error) { throw normalizeApiError(error, "Unable to load bills."); } + } catch (error) { throw normalizeApiError(error, "api.bills.load"); } }, async getAsync(organizationId: string, billId: string) { try { return map((await authApi.get(`/organizations/${organizationId}/bills/${billId}`)).data); } - catch (error) { throw normalizeApiError(error, "Unable to load this bill."); } + catch (error) { throw normalizeApiError(error, "api.bills.loadOne"); } }, async createAsync(organizationId: string, input: BillInput) { try { return map((await authApi.post(`/organizations/${organizationId}/bills`, input)).data); } - catch (error) { throw normalizeApiError(error, "Unable to create the bill."); } + catch (error) { throw normalizeApiError(error, "api.bills.create"); } }, async updateAsync(organizationId: string, billId: string, input: Omit) { try { return map((await authApi.patch(`/organizations/${organizationId}/bills/${billId}`, input)).data); } - catch (error) { throw normalizeApiError(error, "Unable to update the bill."); } + catch (error) { throw normalizeApiError(error, "api.bills.update"); } }, async deleteAsync(organizationId: string, billId: string) { try { await authApi.delete(`/organizations/${organizationId}/bills/${billId}`); } - catch (error) { throw normalizeApiError(error, "Unable to delete the bill."); } + catch (error) { throw normalizeApiError(error, "api.bills.delete"); } }, async uploadDocumentAsync(organizationId: string, billId: string, file: File, fileCategory: string) { try { const form = new FormData(); form.append("file", file); form.append("fileCategory", fileCategory); return (await authApi.post(`/organizations/${organizationId}/bills/${billId}/documents`, form)).data; } - catch (error) { throw normalizeApiError(error, "Unable to upload the bill document."); } + catch (error) { throw normalizeApiError(error, "api.bills.uploadDocument"); } }, async getDocumentAsync(organizationId: string, billId: string, documentId: string) { try { return (await authApi.get(`/organizations/${organizationId}/bills/${billId}/documents/${documentId}`, { responseType: "blob" })).data; } - catch (error) { throw normalizeApiError(error, "Unable to open the bill document."); } + catch (error) { throw normalizeApiError(error, "api.bills.openDocument"); } }, async getDocumentDownloadUrlAsync(organizationId: string, billId: string, documentId: string) { try { return (await authApi.get<{ url: string; fileName: string; contentType: string; expiresAt: string }>(`/organizations/${organizationId}/bills/${billId}/documents/${documentId}/download-url`)).data; } - catch (error) { throw normalizeApiError(error, "Unable to prepare the download."); } + catch (error) { throw normalizeApiError(error, "api.bills.prepareDownload"); } }, async deleteDocumentAsync(organizationId: string, billId: string, documentId: string) { try { await authApi.delete(`/organizations/${organizationId}/bills/${billId}/documents/${documentId}`); } - catch (error) { throw normalizeApiError(error, "Unable to remove the bill document."); } + catch (error) { throw normalizeApiError(error, "api.bills.removeDocument"); } }, async stopSeriesAsync(organizationId: string, seriesId: string) { try { await authApi.post(`/organizations/${organizationId}/bills/series/${seriesId}/stop`); } - catch (error) { throw normalizeApiError(error, "Unable to stop future bills."); } + catch (error) { throw normalizeApiError(error, "api.bills.stopFuture"); } }, }; diff --git a/apps/frontend-v2/src/api/dashboard/dashboard.service.ts b/apps/frontend-v2/src/api/dashboard/dashboard.service.ts index 6caba15..5e54cb1 100644 --- a/apps/frontend-v2/src/api/dashboard/dashboard.service.ts +++ b/apps/frontend-v2/src/api/dashboard/dashboard.service.ts @@ -9,14 +9,14 @@ const lower = (value: string) => value.toLowerCase(); export const dashboardService = { async getSummaryAsync(organizationId: string, filters?: DateFilters) { try { return (await authApi.get(`/organizations/${organizationId}/dashboard/summary`, { params: params(filters) })).data; } - catch (error) { throw normalizeApiError(error, "Unable to load the dashboard summary."); } + catch (error) { throw normalizeApiError(error, "api.dashboard.summary"); } }, async getUpcomingBillsAsync(organizationId: string, filters?: DateFilters): Promise { try { const data = (await authApi.get<{ data: DashboardBill[] }>(`/organizations/${organizationId}/dashboard/upcoming-bills`, { params: params(filters) })).data.data; return data.map((item) => ({ ...item, category: lower(item.category), status: lower(item.status) })); } - catch (error) { throw normalizeApiError(error, "Unable to load upcoming bills."); } + catch (error) { throw normalizeApiError(error, "api.dashboard.upcoming"); } }, async getRecentExpensesAsync(organizationId: string, filters?: DateFilters): Promise { try { const data = (await authApi.get<{ data: DashboardExpense[] }>(`/organizations/${organizationId}/dashboard/recent-expenses`, { params: params(filters) })).data.data; return data.map((item) => ({ ...item, category: lower(item.category) })); } - catch (error) { throw normalizeApiError(error, "Unable to load recent expenses."); } + catch (error) { throw normalizeApiError(error, "api.dashboard.recent"); } }, }; diff --git a/apps/frontend-v2/src/api/expenses/expenses.service.ts b/apps/frontend-v2/src/api/expenses/expenses.service.ts index 94b212a..69cb3a9 100644 --- a/apps/frontend-v2/src/api/expenses/expenses.service.ts +++ b/apps/frontend-v2/src/api/expenses/expenses.service.ts @@ -9,34 +9,34 @@ const map = (wire: ExpenseWire): Expense => ({ ...wire, category: wire.category. export const expensesService = { async listAsync(filters: ExpenseListFilters): Promise { try { const response = await authApi.get<{ data: ExpenseWire[]; page: number; pageSize: number; totalRecords: number; totalPages: number }>(`/organizations/${filters.organizationId}/expenses`, { params: { page: filters.page, pageSize: filters.pageSize, from: filters.from?.toISOString(), to: filters.to?.toISOString() } }); return { ...response.data, data: response.data.data.map(map) }; } - catch (error) { throw normalizeApiError(error, "Unable to load expenses."); } + catch (error) { throw normalizeApiError(error, "api.expenses.load"); } }, async getAsync(organizationId: string, expenseId: string) { try { return map((await authApi.get(`/organizations/${organizationId}/expenses/${expenseId}`)).data); } - catch (error) { throw normalizeApiError(error, "Unable to load this expense."); } + catch (error) { throw normalizeApiError(error, "api.expenses.loadOne"); } }, async createAsync(organizationId: string, input: ExpenseInput & { createdBy: string }) { try { return map((await authApi.post(`/organizations/${organizationId}/expenses`, input)).data); } - catch (error) { throw normalizeApiError(error, "Unable to create the expense."); } + catch (error) { throw normalizeApiError(error, "api.expenses.create"); } }, async updateAsync(organizationId: string, expenseId: string, input: ExpenseInput) { try { return map((await authApi.patch(`/organizations/${organizationId}/expenses/${expenseId}`, input)).data); } - catch (error) { throw normalizeApiError(error, "Unable to update the expense."); } + catch (error) { throw normalizeApiError(error, "api.expenses.update"); } }, async deleteAsync(organizationId: string, expenseId: string) { try { await authApi.delete(`/organizations/${organizationId}/expenses/${expenseId}`); } - catch (error) { throw normalizeApiError(error, "Unable to delete the expense."); } + catch (error) { throw normalizeApiError(error, "api.expenses.delete"); } }, async uploadDocumentAsync(organizationId: string, expenseId: string, file: File, fileCategory: string) { try { const form = new FormData(); form.append("file", file); form.append("fileCategory", fileCategory); return (await authApi.post(`/organizations/${organizationId}/expenses/${expenseId}/documents`, form)).data; } - catch (error) { throw normalizeApiError(error, "Unable to upload the expense document."); } + catch (error) { throw normalizeApiError(error, "api.expenses.uploadDocument"); } }, async getDocumentAsync(organizationId: string, expenseId: string, attachmentId: string) { try { return (await authApi.get(`/organizations/${organizationId}/expenses/${expenseId}/documents/${attachmentId}`, { responseType: "blob" })).data; } - catch (error) { throw normalizeApiError(error, "Unable to open the expense document."); } + catch (error) { throw normalizeApiError(error, "api.expenses.openDocument"); } }, async deleteDocumentAsync(organizationId: string, expenseId: string, attachmentId: string) { try { await authApi.delete(`/organizations/${organizationId}/expenses/${expenseId}/documents/${attachmentId}`); } - catch (error) { throw normalizeApiError(error, "Unable to remove the expense document."); } + catch (error) { throw normalizeApiError(error, "api.expenses.removeDocument"); } }, }; diff --git a/apps/frontend-v2/src/api/health/health.service.ts b/apps/frontend-v2/src/api/health/health.service.ts index 0de2d28..4c9ec1d 100644 --- a/apps/frontend-v2/src/api/health/health.service.ts +++ b/apps/frontend-v2/src/api/health/health.service.ts @@ -1,11 +1,12 @@ import { env } from "../../env"; +import i18n from "../../i18n"; export type HealthStatus = { status: "healthy" | "degraded"; message?: string }; export const healthService = { async getAsync(): Promise { const response = await fetch(env.VITE_HEALTH_URL, { credentials: "include" }); - if (!response.ok) throw new Error(`Health check failed with ${response.status}`); + if (!response.ok) throw new Error(i18n.t("api.healthFailed", { status: response.status })); return { status: "healthy" }; }, }; diff --git a/apps/frontend-v2/src/api/notifications/notifications.service.ts b/apps/frontend-v2/src/api/notifications/notifications.service.ts new file mode 100644 index 0000000..d9174c1 --- /dev/null +++ b/apps/frontend-v2/src/api/notifications/notifications.service.ts @@ -0,0 +1,30 @@ +import { authApi } from "../shared/client"; +import { normalizeApiError } from "../shared/errors"; +import type { NotificationPage, NotificationPreferences } from "./notifications.types"; + +export const notificationsService = { + async listAsync(organizationId: string): Promise { + try { return (await authApi.get(`/organizations/${organizationId}/notifications`, { params: { page: 1, pageSize: 25, unreadOnly: true } })).data; } + catch (error) { throw normalizeApiError(error, "api.notifications.load"); } + }, + async unreadCountAsync(organizationId: string): Promise { + try { return (await authApi.get<{ count: number }>(`/organizations/${organizationId}/notifications/unread-count`)).data.count; } + catch (error) { throw normalizeApiError(error, "api.notifications.load"); } + }, + async markReadAsync(organizationId: string, notificationId: string): Promise { + try { await authApi.patch(`/organizations/${organizationId}/notifications/${notificationId}/read`); } + catch (error) { throw normalizeApiError(error, "api.notifications.markRead"); } + }, + async markAllReadAsync(organizationId: string): Promise { + try { await authApi.post(`/organizations/${organizationId}/notifications/read-all`); } + catch (error) { throw normalizeApiError(error, "api.notifications.markRead"); } + }, + async getPreferencesAsync(organizationId: string): Promise { + try { return (await authApi.get(`/organizations/${organizationId}/notification-preferences`)).data; } + catch (error) { throw normalizeApiError(error, "api.notifications.loadPreferences"); } + }, + async updatePreferencesAsync(organizationId: string, enabled: boolean): Promise { + try { return (await authApi.put(`/organizations/${organizationId}/notification-preferences`, { emailBillRemindersEnabled: enabled })).data; } + catch (error) { throw normalizeApiError(error, "api.notifications.savePreferences"); } + }, +}; diff --git a/apps/frontend-v2/src/api/notifications/notifications.types.ts b/apps/frontend-v2/src/api/notifications/notifications.types.ts new file mode 100644 index 0000000..f3beedb --- /dev/null +++ b/apps/frontend-v2/src/api/notifications/notifications.types.ts @@ -0,0 +1,27 @@ +import type { Paged } from "../bills/bills.types"; + +export type NotificationType = "BillDueSoon" | "BillDueToday" | "BillOverdue" | "MemberJoined" | "MemberRoleChanged" | "MemberRemoved"; + +export interface NotificationParameters { + billId?: string; + billDescription?: string; + amountDue?: number; + dueDate?: string; + memberUserId?: string; + memberName?: string; + actorName?: string; + previousRole?: string; + newRole?: string; +} + +export interface AppNotification { + id: string; + type: NotificationType; + parameters: NotificationParameters; + actionPath: string; + createdAt: string; + readAt: string | null; +} + +export type NotificationPage = Paged; +export interface NotificationPreferences { emailBillRemindersEnabled: boolean; emailAvailable: boolean } diff --git a/apps/frontend-v2/src/api/organizations/organizations.service.ts b/apps/frontend-v2/src/api/organizations/organizations.service.ts index cbae7c8..77eec5c 100644 --- a/apps/frontend-v2/src/api/organizations/organizations.service.ts +++ b/apps/frontend-v2/src/api/organizations/organizations.service.ts @@ -9,44 +9,44 @@ export type EditableOrganizationMemberRole = "Admin" | "Member"; export const organizationsService = { async listAsync(): Promise { try { return (await authApi.get("/organizations")).data; } - catch (error) { throw normalizeApiError(error, "Unable to load organizations."); } + catch (error) { throw normalizeApiError(error, "api.organizations.load"); } }, async getAsync(organizationId: string): Promise { try { return (await authApi.get(`/organizations/${organizationId}`)).data; } - catch (error) { throw normalizeApiError(error, "Unable to load this organization."); } + catch (error) { throw normalizeApiError(error, "api.organizations.loadOne"); } }, async createAsync(name: string): Promise { try { return (await authApi.post("/organizations", { name })).data; } - catch (error) { throw normalizeApiError(error, "Unable to create the organization."); } + catch (error) { throw normalizeApiError(error, "api.organizations.create"); } }, async updateAsync(organizationId: string, name: string): Promise { try { return (await authApi.patch(`/organizations/${organizationId}`, { name })).data; } - catch (error) { throw normalizeApiError(error, "Unable to update the organization."); } + catch (error) { throw normalizeApiError(error, "api.organizations.update"); } }, async getBudgetAsync(organizationId: string): Promise { try { return (await authApi.get(`/organizations/${organizationId}/budget`)).data; } - catch (error) { const normalized = normalizeApiError(error, "Unable to load the budget."); if (normalized.status === 404) return null; throw normalized; } + catch (error) { const normalized = normalizeApiError(error, "api.organizations.loadBudget"); if (normalized.status === 404) return null; throw normalized; } }, async upsertBudgetAsync(organizationId: string, amount: number): Promise { try { return (await authApi.put(`/organizations/${organizationId}/budget`, { amount })).data; } - catch (error) { throw normalizeApiError(error, "Unable to save the budget."); } + catch (error) { throw normalizeApiError(error, "api.organizations.saveBudget"); } }, async createInviteAsync(organizationId: string, email: string, role: EditableOrganizationMemberRole): Promise { try { const roleValue = { Admin: 2, Member: 3 }[role]; return (await authApi.post(`/organizations/${organizationId}/invite`, { email, role: roleValue })).data; - } catch (error) { throw normalizeApiError(error, "Unable to create the invitation."); } + } catch (error) { throw normalizeApiError(error, "api.organizations.createInvitation"); } }, async updateMemberRoleAsync(organizationId: string, userId: string, role: EditableOrganizationMemberRole): Promise { try { await authApi.patch(`/organizations/${organizationId}/members/${userId}/role`, { role }); } - catch (error) { throw normalizeApiError(error, "Unable to update this member's role."); } + catch (error) { throw normalizeApiError(error, "api.organizations.updateRole"); } }, async removeMemberAsync(organizationId: string, userId: string): Promise { try { await authApi.delete(`/organizations/${organizationId}/members/${userId}`); } - catch (error) { throw normalizeApiError(error, "Unable to remove this member."); } + catch (error) { throw normalizeApiError(error, "api.organizations.removeMember"); } }, async joinAsync(token: string): Promise { try { await authApi.post(`/organizations/join?token=${encodeURIComponent(token)}`); } - catch (error) { throw normalizeApiError(error, "Unable to join the organization."); } + catch (error) { throw normalizeApiError(error, "api.organizations.join"); } }, }; diff --git a/apps/frontend-v2/src/api/shared/errors.ts b/apps/frontend-v2/src/api/shared/errors.ts index 89c76c9..8dd5beb 100644 --- a/apps/frontend-v2/src/api/shared/errors.ts +++ b/apps/frontend-v2/src/api/shared/errors.ts @@ -1,5 +1,7 @@ import axios, { isAxiosError } from "axios"; +import i18n from "../../i18n"; + export class ApiError extends Error { readonly status: number | undefined; readonly code: string | undefined; @@ -14,7 +16,7 @@ export class ApiError extends Error { } } -export function normalizeApiError(error: unknown, fallback: string): ApiError { +export function normalizeApiError(error: unknown, fallbackKey: string): ApiError { if (error instanceof ApiError) return error; if (isAxiosError(error)) { @@ -27,14 +29,14 @@ export function normalizeApiError(error: unknown, fallback: string): ApiError { : typeof data?.description === "string" ? data.description : errors - ? "Please check the highlighted fields." - : fallback; + ? i18n.t("errors.validation") + : i18n.t(fallbackKey); return new ApiError(message, error.response?.status, typeof data?.code === "string" ? data.code : undefined, errors); } - if (axios.isCancel(error)) return new ApiError("Request canceled."); - return new ApiError(error instanceof Error ? error.message : fallback); + if (axios.isCancel(error)) return new ApiError(i18n.t("errors.requestCanceled")); + return new ApiError(error instanceof Error ? error.message : i18n.t(fallbackKey)); } export function isUnauthorized(error: unknown) { diff --git a/apps/frontend-v2/src/app.tsx b/apps/frontend-v2/src/app.tsx index eaca7aa..c61e947 100644 --- a/apps/frontend-v2/src/app.tsx +++ b/apps/frontend-v2/src/app.tsx @@ -20,13 +20,14 @@ import { organizationsService } from "./api/organizations/organizations.service" import { useAuth } from "./auth/auth-provider"; import { useOrganizationStore } from "./auth/auth-store"; import { formatCurrency, formatDate, formatLongDate, inputDate, relativeDate } from "./format"; -import { useAccountMutations, useBillMutations, useBillsQuery, useBudgetQuery, useDashboardQueries, useExpenseMutations, useExpensesQuery, useOrganizationMemberMutations, useOrganizationMutations, useOrganizationQuery, useBillQuery, useExpenseQuery } from "./hooks/use-queries"; +import { useAccountMutations, useBillMutations, useBillsQuery, useBudgetQuery, useDashboardQueries, useExpenseMutations, useExpensesQuery, useNotificationMutations, useNotificationPreferencesQuery, useOrganizationMemberMutations, useOrganizationMutations, useOrganizationQuery, useBillQuery, useExpenseQuery } from "./hooks/use-queries"; import { useDebounce } from "./hooks/use-debounce"; +import { useTheme } from "./hooks/use-theme"; import { ActionMenu, AppShell, Avatar, Button, DataIcon, EmptyState, IconButton, KpiSparkline, MetricCard, Modal, PageContainer, PageHeader, PublicLayout, QuickAction, SectionHeading, StatusPill } from "./ui"; import type { DashboardBill, DashboardExpense } from "./api/dashboard/dashboard.types"; const categoryLabels: Record = { - housing: "Housing", utilities: "Utilities", food: "Food", transportation: "Transport", healthcare: "Healthcare", subscriptions: "Subscriptions", education: "Education", insurance: "Insurance", personal: "Personal", taxes: "Taxes", miscellaneous: "Misc", travel: "Travel", gifts: "Gifts", pets: "Pets", + housing: "types.housing", utilities: "types.utilities", food: "types.food", transportation: "types.transportation", healthcare: "types.healthcare", subscriptions: "types.subscriptions", education: "types.education", insurance: "types.insurance", personal: "types.personal", taxes: "types.taxes", miscellaneous: "types.miscellaneous", travel: "types.travel", gifts: "types.gifts", pets: "types.pets", }; const categories = Object.keys(categoryLabels) as [BillCategory, ...BillCategory[]]; const documentCategories: FileCategory[] = ["Invoice", "Receipt", "Boleto", "Contract", "Other"]; @@ -54,18 +55,21 @@ function useSelectedOrganization() { return id; } -function LoadingState({ label = "Loading" }: { label?: string }) { - return

{label}

; +function LoadingState({ label }: { label?: string }) { + const { t } = useTranslation(); + return
; } function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { - return Try again} />; + const { t } = useTranslation(); + return {t("errors.tryAgain")}} />; } function ProtectedRoute({ children }: { children: ReactNode }) { + const { t } = useTranslation(); const auth = useAuth(); const location = useLocation(); - if (auth.status === "initializing") return ; + if (auth.status === "initializing") return ; if (auth.status !== "authenticated") { const returnTo = `${location.pathname}${location.search}`; return ; @@ -100,7 +104,7 @@ function HomePage() { const { t, i18n } = useTranslation(); const { status } = useAuth(); const authenticated = status === "authenticated"; - return

{t("home.eyebrow")}

{t("home.title")}

{t("home.body")}

{t("home.cta")} {t("home.secondary")}
41 routes connected to one calm workspace
{i18n.language === "pt-BR" ? "Fluxo financeiro" : "Cash flow"}
{i18n.language === "pt-BR" ? "Disponível" : "Available"}{formatCurrency(2940, i18n.language)} Live workspace data
0108152230
Upcoming bills {formatCurrency(2469.9, i18n.language)} Spent this month {formatCurrency(2260, i18n.language)}
+{formatCurrency(320, i18n.language)}payment cleared
Ready for the next decisionwith live account context

{t("home.signal")}

Every number has a next step.

01

Know what’s committed

See upcoming obligations before they crowd out the choices you actually want to make.

02

Notice the pattern

Turn a pile of transactions into a rhythm you can talk about together.

03

Keep it shared

Invite the people who need context, without turning your home into a spreadsheet.

; + return

{t("home.eyebrow")}

{t("home.title")}

{t("home.body")}

{t("home.cta")} {t("home.secondary")}
{t("home.routes", { count: 41 })}
{t("home.cashFlow")}
{t("home.available")}{formatCurrency(2940, i18n.language)} {t("home.liveData")}
0108152230
{t("home.upcomingBills")} {formatCurrency(2469.9, i18n.language)} {t("home.spentThisMonth")} {formatCurrency(2260, i18n.language)}
+{formatCurrency(320, i18n.language)}{t("home.paymentCleared")}
{t("home.readyNext")}{t("home.liveContext")}

{t("home.signal")}

{t("home.nextStep")}

01

{t("home.committed")}

{t("home.committedBody")}

02

{t("home.pattern")}

{t("home.patternBody")}

03

{t("home.shared")}

{t("home.sharedBody")}

; } function safeReturnTo(value: string | null) { @@ -121,16 +125,16 @@ function AuthPage({ mode }: { mode: "sign-in" | "sign-up" }) { const schema = z.object({ email: z.string().email(), password: z.string().min(isSignIn ? 1 : 8) }); const values = { email: String(data.get("email") ?? ""), password: String(data.get("password") ?? "") }; const parsed = schema.safeParse(values); - if (!parsed.success) { setError(isSignIn ? "Use a valid email and password." : "Use a valid email and a password with at least 8 characters."); return; } + if (!parsed.success) { setError(isSignIn ? t("auth.validCredentials") : t("auth.validRegistration")); return; } setPending(true); try { const user = await auth.signIn(isSignIn ? values : { ...values, firstName: String(data.get("firstName") ?? ""), lastName: String(data.get("lastName") ?? "") }); - toast.success(isSignIn ? "Welcome back" : "Account created"); + toast.success(isSignIn ? t("auth.welcomeBack") : t("auth.accountCreated")); navigate(user.organizations.length ? safeReturnTo(searchParams.get("returnTo")) : "/account/create-organization", { replace: true }); - } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "Unable to continue."); } + } catch (nextError) { setError(nextError instanceof Error ? nextError.message : t("auth.unableContinue")); } finally { setPending(false); } }; - return

BitFinance / finance desk

{isSignIn ? t("auth.signInTitle") : t("auth.signUpTitle")}

{isSignIn ? t("auth.signInBody") : t("auth.signUpBody")}

{isSignIn ? "Your session is protected by an HTTP-only refresh cookie." : "Your account starts with an eight-character minimum password."}
BF / live
← {i18n.language === "pt-BR" ? "Voltar ao início" : "Back to home"}
{isSignIn ? : }

01 / {isSignIn ? "sign in" : "get started"}

{isSignIn ? t("common.signIn") : t("common.signUp")}

{!isSignIn &&
}{error &&

{error}

}

{isSignIn ? t("auth.noAccount") : t("auth.haveAccount")} {isSignIn ? t("common.signUp") : t("common.signIn")}

Live account data stays on the server
; + return

BitFinance / {t("common.financeDesk")}

{isSignIn ? t("auth.signInTitle") : t("auth.signUpTitle")}

{isSignIn ? t("auth.signInBody") : t("auth.signUpBody")}

{isSignIn ? t("auth.protectedSession") : t("auth.minimumPassword")}
BF / live
← {t("common.backHome")}
{isSignIn ? : }

01 / {isSignIn ? t("auth.signInStep") : t("auth.getStarted")}

{isSignIn ? t("common.signIn") : t("common.signUp")}

{!isSignIn &&
}{error &&

{error}

}

{isSignIn ? t("auth.noAccount") : t("auth.haveAccount")} {isSignIn ? t("common.signUp") : t("common.signIn")}

{t("auth.serverData")}
; } function JoinPage() { @@ -147,20 +151,20 @@ function JoinPage() { const joinedOrganization = nextUser.organizations.find((organization) => !previousOrganizationIds.has(organization.id)); const selectedOrganization = joinedOrganization ?? nextUser.organizations[0]; if (selectedOrganization) useOrganizationStore.getState().setSelectedOrganizationId(selectedOrganization.id); - toast.success("You joined the organization"); + toast.success(t("join.joined")); navigate("/dashboard", { replace: true }); }, - onError: (error) => setMessage(error instanceof Error ? error.message : "This invitation cannot be used."), + onError: (error) => setMessage(error instanceof Error ? error.message : t("join.invalid")), }); const signInUrl = `/auth/sign-in?returnTo=${encodeURIComponent(`/join-organization?token=${encodeURIComponent(token ?? "")}`)}`; - return

Invitation / live

{token ? "Join this organization" : "Invitation link missing"}

{token ? "Accept the invitation after signing in to add this organization to your workspace." : "Ask the sender for a fresh invitation link."}

{message &&

{message}

}{auth.status === "authenticated" && token ? : {t("common.signIn")} }
; + return

{t("join.eyebrow")}

{token ? t("join.title") : t("join.missingTitle")}

{token ? t("join.body") : t("join.missingBody")}

{message &&

{message}

}{auth.status === "authenticated" && token ? : {t("common.signIn")} }
; } function CreateOrganizationPage() { - const auth = useAuth(); const navigate = useNavigate(); const [name, setName] = useState(""); - const create = useMutation({ mutationFn: () => organizationsService.createAsync(name.trim()), onSuccess: async (organization) => { await auth.refreshUser(); useOrganizationStore.getState().setSelectedOrganizationId(organization.id); toast.success("Organization created"); navigate("/dashboard", { replace: true }); } }); - if (auth.status === "initializing") return ; - return

New workspace / 01

Create a money desk

Give the workspace a name. You can invite people and set a budget from the organization area.

{create.error &&

{create.error instanceof Error ? create.error.message : "Unable to create the workspace."}

}
; + const { t } = useTranslation(); const auth = useAuth(); const navigate = useNavigate(); const [name, setName] = useState(""); + const create = useMutation({ mutationFn: () => organizationsService.createAsync(name.trim()), onSuccess: async (organization) => { await auth.refreshUser(); useOrganizationStore.getState().setSelectedOrganizationId(organization.id); toast.success(t("createOrganization.created")); navigate("/dashboard", { replace: true }); } }); + if (auth.status === "initializing") return ; + return

{t("createOrganization.eyebrow")}

{t("createOrganization.title")}

{t("createOrganization.body")}

{create.error &&

{create.error instanceof Error ? create.error.message : t("createOrganization.unable")}

}
; } function useCurrentMonth() { @@ -183,36 +187,36 @@ function useCurrentMonth() { function DashboardPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const { user } = useAuth(); const queries = useDashboardQueries(organizationId, month.from, month.to); const summary = queries.summary.data; const upcoming = queries.upcoming.data ?? []; const recent = queries.recent.data ?? []; - if (!organizationId) return Create workspace} />; - const loading = queries.summary.isPending || queries.upcoming.isPending || queries.recent.isPending; const failed = queries.summary.error || queries.upcoming.error || queries.recent.error; const name = user?.fullName.split(" ")[0] ?? "there"; const budgetLabel = summary?.monthlyBudget == null ? "Not set" : formatCurrency(summary.monthlyBudget, locale); const spent = summary?.spentThisMonth ?? 0; const spentPercentage = summary?.spentPercentage ?? 0; - return {formatDate(month.from.toISOString(), locale)} — {formatDate(month.to.toISOString(), locale)} } />{loading && !summary ? : failed && !summary ? { void queries.summary.refetch(); void queries.upcoming.refetch(); void queries.recent.refetch(); }} /> : <>
{t("dashboard.onTrack")}

Your committed money is {formatCurrency(summary?.upcomingBillsAmount ?? 0, locale)} this period.

{queries.upcoming.error ? { void queries.upcoming.refetch(); }} /> : }{queries.recent.error ? { void queries.recent.refetch(); }} /> :
{t("common.viewAll")} } />
{upcoming.slice(0, 3).map((bill) => {bill.description}{formatDate(bill.dueDate, locale)} · {categoryLabels[bill.category]}{formatCurrency(bill.amountDue, locale)})}{!upcoming.length && }
{t("common.viewAll")} } />
item.amount) : [0]} color="#23b89a" />{formatCurrency(spent, locale)}across {recent.length} transactions
}
}
; + if (!organizationId) return {t("dashboard.createWorkspace")}} />; + const loading = queries.summary.isPending || queries.upcoming.isPending || queries.recent.isPending; const failed = queries.summary.error || queries.upcoming.error || queries.recent.error; const name = user?.fullName.split(" ")[0] ?? t("dashboard.greetingFallback"); const budgetLabel = summary?.monthlyBudget == null ? t("dashboard.notSet") : formatCurrency(summary.monthlyBudget, locale); const spent = summary?.spentThisMonth ?? 0; const spentPercentage = summary?.spentPercentage ?? 0; + return {formatDate(month.from.toISOString(), locale)} — {formatDate(month.to.toISOString(), locale)} } />{loading && !summary ? : failed && !summary ? { void queries.summary.refetch(); void queries.upcoming.refetch(); void queries.recent.refetch(); }} /> : <>
{t("dashboard.onTrack")}

{t("dashboard.committedMoney", { amount: formatCurrency(summary?.upcomingBillsAmount ?? 0, locale) })}

{queries.upcoming.error ? { void queries.upcoming.refetch(); }} /> : }{queries.recent.error ? { void queries.recent.refetch(); }} /> :
{t("common.viewAll")} } />
{upcoming.slice(0, 3).map((bill) => {bill.description}{formatDate(bill.dueDate, locale)} · {t(categoryLabels[bill.category])}{formatCurrency(bill.amountDue, locale)})}{!upcoming.length && }
{t("common.viewAll")} } />
item.amount) : [0]} color="#23b89a" />{formatCurrency(spent, locale)}{t("common.transactionCount", { count: recent.length })}
}
}
; } function categoryPercentage(items: DashboardExpense[], categoriesToCount: string[]) { const total = items.reduce((sum, item) => sum + item.amount, 0); return total ? Math.round(items.filter((item) => categoriesToCount.includes(item.category)).reduce((sum, item) => sum + item.amount, 0) / total * 100) : 0; } function CategoryBar({ label, value, color }: { label: string; value: number; color: string }) { return
{label}{value}%
; } -function CashflowTimeline({ bills, expenses, locale }: { bills: DashboardBill[]; expenses: DashboardExpense[]; locale: string }) { const { t } = useTranslation(); const events = [...bills.map((bill) => ({ date: bill.dueDate, label: bill.description, amount: bill.amountDue, kind: "bill" as const })), ...expenses.slice(0, 3).map((expense) => ({ date: expense.date, label: expense.description, amount: expense.amount, kind: "expense" as const }))].sort((a, b) => a.date.localeCompare(b.date)); return

{t("dashboard.flow")}

{t("dashboard.flowBody")}

commitments moved
{events.map((event, index) =>
{formatDate(event.date, locale)}{event.label}{event.kind === "expense" ? "−" : ""}{formatCurrency(event.amount, locale)}
)}
{!events.length && }
; } +function CashflowTimeline({ bills, expenses, locale }: { bills: DashboardBill[]; expenses: DashboardExpense[]; locale: string }) { const { t } = useTranslation(); const events = [...bills.map((bill) => ({ date: bill.dueDate, label: bill.description, amount: bill.amountDue, kind: "bill" as const })), ...expenses.slice(0, 3).map((expense) => ({ date: expense.date, label: expense.description, amount: expense.amount, kind: "expense" as const }))].sort((a, b) => a.date.localeCompare(b.date)); return

{t("dashboard.flow")}

{t("dashboard.flowBody")}

{t("dashboard.timelineCommitments")} {t("dashboard.timelineMoved")}
{events.map((event, index) =>
{formatDate(event.date, locale)}{event.label}{event.kind === "expense" ? "−" : ""}{formatCurrency(event.amount, locale)}
)}
{!events.length && }
; } function BillsPage() { - const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search); const [status, setStatus] = useState("all"); const [series, setSeries] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useBillsQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to, status: status === "all" ? undefined : status, description: debouncedSearch } : null); const mutations = useBillMutations(organizationId); const rows = (query.data?.data ?? []).filter((bill) => series === "all" || bill.billSeriesType === series); const total = rows.reduce((sum, bill) => sum + bill.amountDue, 0); const due = rows.filter((bill) => ["due", "overdue"].includes(bill.status)).reduce((sum, bill) => sum + bill.amountDue, 0); const paid = rows.filter((bill) => bill.status === "paid").reduce((sum, bill) => sum + (bill.amountPaid ?? 0), 0); const clear = () => { setSearch(""); setStatus("all"); setSeries("all"); setPage(1); }; const remove = (id: string) => { if (window.confirm("Delete this bill?")) mutations.remove.mutate(id, { onSuccess: () => toast.success("Bill removed"), onError: (error) => toast.error(error.message) }); }; const markPaid = (bill: Bill) => mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success("Bill marked as paid"), onError: (error) => toast.error(error.message) }); - if (!organizationId) return ; - return setModal("add")}> {t("bills.add")}} />
{t("bills.total")}{formatCurrency(total, locale)}
{t("bills.due")}{formatCurrency(due, locale)}
{t("bills.paid")}{formatCurrency(paid, locale)}
{(search || status !== "all" || series !== "all") && }
{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
CommitmentDue dateTypeAmountStatus
{rows.length ? rows.map((bill) => { setSelected(bill); setModal("edit"); }} onDelete={() => remove(bill.id)} onPaid={() => markPaid(bill)} />) : {t("common.clearFilters")}} />}}
{query.data && query.data.totalPages > 1 && }{modal === "add" && setModal(null)} organizationId={organizationId} />}{modal === "edit" && selected && { setModal(null); setSelected(null); }} organizationId={organizationId} />}
; + const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search); const [status, setStatus] = useState("all"); const [series, setSeries] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useBillsQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to, status: status === "all" ? undefined : status, description: debouncedSearch } : null); const mutations = useBillMutations(organizationId); const rows = (query.data?.data ?? []).filter((bill) => series === "all" || bill.billSeriesType === series); const total = rows.reduce((sum, bill) => sum + bill.amountDue, 0); const due = rows.filter((bill) => ["due", "overdue"].includes(bill.status)).reduce((sum, bill) => sum + bill.amountDue, 0); const paid = rows.filter((bill) => bill.status === "paid").reduce((sum, bill) => sum + (bill.amountPaid ?? 0), 0); const clear = () => { setSearch(""); setStatus("all"); setSeries("all"); setPage(1); }; const remove = (id: string) => { if (window.confirm(t("common.deleteBillConfirm"))) mutations.remove.mutate(id, { onSuccess: () => toast.success(t("bills.removed")), onError: (error) => toast.error(error.message) }); }; const markPaid = (bill: Bill) => mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) }); + if (!organizationId) return ; + return setModal("add")}> {t("bills.add")}} />
{t("bills.total")}{formatCurrency(total, locale)}
{t("bills.due")}{formatCurrency(due, locale)}
{t("bills.paid")}{formatCurrency(paid, locale)}
{(search || status !== "all" || series !== "all") && }
{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("bills.commitment")}{t("bills.dueDate")}{t("bills.type")}{t("common.amount")}{t("bills.status")}
{rows.length ? rows.map((bill) => { setSelected(bill); setModal("edit"); }} onDelete={() => remove(bill.id)} onPaid={() => markPaid(bill)} />) : {t("common.clearFilters")}} />}}
{query.data && query.data.totalPages > 1 && }{modal === "add" && setModal(null)} organizationId={organizationId} />}{modal === "edit" && selected && { setModal(null); setSelected(null); }} organizationId={organizationId} />}
; } -function Pagination({ page, totalPages, onPageChange }: { page: number; totalPages: number; onPageChange: (page: number) => void }) { return
{page} / {totalPages}
; } -function BillRow({ bill, locale, onDetails, onDelete, onPaid }: { bill: Bill; locale: string; onDetails: () => void; onDelete: () => void; onPaid: () => void }) { return
{bill.description}{categoryLabels[bill.category]}{bill.billSeriesType === "installment" && ` · ${bill.occurrenceNumber}/${bill.totalOccurrences} installment`}
{formatDate(bill.dueDate, locale)}{relativeDate(bill.dueDate)}{bill.billSeriesType ? {bill.billSeriesType} : One time}{formatCurrency(bill.amountDue, locale)}
; } +function Pagination({ page, totalPages, onPageChange }: { page: number; totalPages: number; onPageChange: (page: number) => void }) { const { t } = useTranslation(); return
{page} / {totalPages}
; } +function BillRow({ bill, locale, onDetails, onDelete, onPaid }: { bill: Bill; locale: string; onDetails: () => void; onDelete: () => void; onPaid: () => void }) { const { t } = useTranslation(); return
{bill.description}{t(categoryLabels[bill.category])}{bill.billSeriesType === "installment" && ` · ${t("common.installmentCount", { current: bill.occurrenceNumber, total: bill.totalOccurrences })}`}
{formatDate(bill.dueDate, locale)}{relativeDate(bill.dueDate, locale)}{bill.billSeriesType ? {t(`types.${bill.billSeriesType}`)} : {t("common.oneTime")}}{formatCurrency(bill.amountDue, locale)}
; } -function BillModal({ bill, onClose, organizationId }: { bill?: Bill; onClose: () => void; organizationId: string }) { const { t } = useTranslation(); const mutations = useBillMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const series = String(data.get("series") ?? "one-time"); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as BillCategory, status: (bill?.status ?? "upcoming") as BillStatus, dueDate: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString(), paymentDate: bill?.paymentDate ?? null, amountDue: Number(data.get("amount") ?? 0), amountPaid: bill?.amountPaid ?? null, frequency: series === "one-time" ? null : String(data.get("frequency") ?? "monthly") as BillFrequency, installments: series === "installment" ? Number(data.get("installments") ?? 1) : null }; const done = () => { toast.success(bill ? "Bill updated" : "Bill created"); onClose(); }; if (bill) mutations.update.mutate({ id: bill.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate(input, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
{!bill && <>}
; } +function BillModal({ bill, onClose, organizationId }: { bill?: Bill; onClose: () => void; organizationId: string }) { const { t } = useTranslation(); const mutations = useBillMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const series = String(data.get("series") ?? "one-time"); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as BillCategory, status: (bill?.status ?? "upcoming") as BillStatus, dueDate: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString(), paymentDate: bill?.paymentDate ?? null, amountDue: Number(data.get("amount") ?? 0), amountPaid: bill?.amountPaid ?? null, frequency: series === "one-time" ? null : String(data.get("frequency") ?? "monthly") as BillFrequency, installments: series === "installment" ? Number(data.get("installments") ?? 1) : null }; const done = () => { toast.success(bill ? t("bills.updated") : t("bills.created")); onClose(); }; if (bill) mutations.update.mutate({ id: bill.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate(input, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
{!bill && <>}
; } -function BillDetailsPage() { const { billId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useBillQuery(organizationId, billId); const mutations = useBillMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Other"); const bill = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error("Use a PDF, JPG, PNG, DOC, or DOCX file up to 10 MiB."); return; } if (billId) mutations.upload.mutate({ id: billId, file, category: fileCategory }, { onSuccess: () => toast.success("Document uploaded"), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await billsService.getDocumentAsync(organizationId!, billId!, documentId); const url = URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; const download = async (documentId: string) => { const response = await billsService.getDocumentDownloadUrlAsync(organizationId!, billId!, documentId); window.open(response.url, "_blank", "noopener,noreferrer"); }; if (query.isPending) return ; if (query.error || !bill) return { void query.refetch(); }} />; return ← Back to bills mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success("Bill marked as paid"), onError: (error) => toast.error(error.message) })}>Mark as paid : } />
Amount due{formatCurrency(bill.amountDue, locale)}
Due date
{formatLongDate(bill.dueDate, locale)}
Category
{categoryLabels[bill.category]}
Schedule
{bill.billSeriesType ? `${bill.billSeriesType} · ${bill.occurrenceNumber ?? ""}` : "One-time"}
{bill.billSeriesId && bill.billSeriesIsActive && }
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{bill.documents.length ? bill.documents.map((document) =>
{document.fileName}{document.fileCategory} { if (window.confirm("Remove this document?")) mutations.removeDocument.mutate({ billId: bill.id, documentId: document.id }, { onSuccess: () => toast.success("Document removed"), onError: (error) => toast.error(error.message) }); }}>
) : }
; } +function BillDetailsPage() { const { t } = useTranslation(); const { billId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useBillQuery(organizationId, billId); const mutations = useBillMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Other"); const bill = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error(t("bills.invalidFile")); return; } if (billId) mutations.upload.mutate({ id: billId, file, category: fileCategory }, { onSuccess: () => toast.success(t("bills.uploaded")), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await billsService.getDocumentAsync(organizationId!, billId!, documentId); const url = URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; const download = async (documentId: string) => { const response = await billsService.getDocumentDownloadUrlAsync(organizationId!, billId!, documentId); window.open(response.url, "_blank", "noopener,noreferrer"); }; if (query.isPending) return ; if (query.error || !bill) return { void query.refetch(); }} />; return ← {t("nav.bills")} mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) })}>{t("bills.markPaid")} : } />
{t("bills.amountDue")}{formatCurrency(bill.amountDue, locale)}
{t("bills.dueDate")}
{formatLongDate(bill.dueDate, locale)}
{t("common.category")}
{t(categoryLabels[bill.category])}
{t("common.schedule")}
{bill.billSeriesType ? `${t(`types.${bill.billSeriesType}`)} · ${bill.occurrenceNumber ?? ""}` : t("common.oneTime")}
{bill.billSeriesId && bill.billSeriesIsActive && }
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{bill.documents.length ? bill.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} { if (window.confirm(t("common.removeDocumentConfirm"))) mutations.removeDocument.mutate({ billId: bill.id, documentId: document.id }, { onSuccess: () => toast.success(t("bills.documentRemoved")), onError: (error) => toast.error(error.message) }); }}>
) : }
; } -function ExpensesPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const { user } = useAuth(); const [search, setSearch] = useState(""); const [status, setStatus] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useExpensesQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to } : null); const mutations = useExpenseMutations(organizationId); const rows = (query.data?.data ?? []).filter((expense) => expense.description.toLowerCase().includes(search.toLowerCase()) && (status === "all" || expense.status === status)); const total = rows.reduce((sum, expense) => sum + expense.amount, 0); const remove = (id: string) => { if (window.confirm("Delete this expense?")) mutations.remove.mutate(id, { onSuccess: () => toast.success("Expense removed"), onError: (error) => toast.error(error.message) }); }; if (!organizationId || !user) return ; return setModal("add")}> {t("expenses.add")}} />
{t("expenses.total")}{formatCurrency(total, locale)}
{t("expenses.transactions")}{rows.length}
Average{formatCurrency(rows.length ? total / rows.length : 0, locale)}

Search and status are local filters over the loaded page; the backend supports pagination and dates only.

{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
ExpenseDateCategoryAmountStatus
{rows.length ? rows.map((expense) => { setSelected(expense); setModal("edit"); }} onDelete={() => remove(expense.id)} />) : }}
{query.data && query.data.totalPages > 1 && }{modal && { setModal(null); setSelected(null); }} organizationId={organizationId} userId={user.id} />}
; } -function ExpenseRow({ expense, locale, onDetails, onDelete }: { expense: Expense; locale: string; onDetails: () => void; onDelete: () => void }) { return
{expense.description}Added {relativeDate(expense.occurredAt)}
{formatDate(expense.occurredAt, locale)}{categoryLabels[expense.category]}{formatCurrency(expense.amount, locale)}
; } -function ExpenseModal({ expense, onClose, organizationId, userId }: { expense?: Expense; onClose: () => void; organizationId: string; userId: string }) { const { t } = useTranslation(); const mutations = useExpenseMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as ExpenseCategory, amount: Number(data.get("amount") ?? 0), status: (expense?.status ?? "paid") as ExpenseStatus, occurredAt: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString() }; const done = () => { toast.success(expense ? "Expense updated" : "Expense added"); onClose(); }; if (expense) mutations.update.mutate({ id: expense.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate({ ...input, createdBy: userId }, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
; } -function ExpenseDetailsPage() { const { expenseId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useExpenseQuery(organizationId, expenseId); const mutations = useExpenseMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Receipt"); const expense = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error("Use a PDF, JPG, PNG, DOC, or DOCX file up to 10 MiB."); return; } if (expenseId) mutations.upload.mutate({ id: expenseId, file, category: fileCategory }, { onSuccess: () => toast.success("Document uploaded"), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await expensesService.getDocumentAsync(organizationId!, expenseId!, documentId); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "expense-document"; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; if (query.isPending) return ; if (query.error || !expense) return { void query.refetch(); }} />; return ← Back to expenses} />
Amount{formatCurrency(expense.amount, locale)}
Occurred
{formatLongDate(expense.occurredAt, locale)}
Category
{categoryLabels[expense.category]}
Created by
{expense.createdBy}
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{expense.documents.length ? expense.documents.map((document) =>
{document.fileName}{document.fileCategory} { if (window.confirm("Remove this document?")) mutations.removeDocument.mutate({ expenseId: expense.id, attachmentId: document.id }, { onSuccess: () => toast.success("Document removed"), onError: (error) => toast.error(error.message) }); }}>
) : }
; } +function ExpensesPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const { user } = useAuth(); const [search, setSearch] = useState(""); const [status, setStatus] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useExpensesQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to } : null); const mutations = useExpenseMutations(organizationId); const rows = (query.data?.data ?? []).filter((expense) => expense.description.toLowerCase().includes(search.toLowerCase()) && (status === "all" || expense.status === status)); const total = rows.reduce((sum, expense) => sum + expense.amount, 0); const remove = (id: string) => { if (window.confirm(t("common.deleteExpenseConfirm"))) mutations.remove.mutate(id, { onSuccess: () => toast.success(t("expenses.removed")), onError: (error) => toast.error(error.message) }); }; if (!organizationId || !user) return ; return setModal("add")}> {t("expenses.add")}} />
{t("expenses.total")}{formatCurrency(total, locale)}
{t("expenses.transactions")}{rows.length}
{t("expenses.average")}{formatCurrency(rows.length ? total / rows.length : 0, locale)}

{t("expenses.localFilters")}

{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("expenses.expense")}{t("expenses.date")}{t("expenses.category")}{t("expenses.amount")}{t("expenses.status")}
{rows.length ? rows.map((expense) => { setSelected(expense); setModal("edit"); }} onDelete={() => remove(expense.id)} />) : }}
{query.data && query.data.totalPages > 1 && }{modal && { setModal(null); setSelected(null); }} organizationId={organizationId} userId={user.id} />}
; } +function ExpenseRow({ expense, locale, onDetails, onDelete }: { expense: Expense; locale: string; onDetails: () => void; onDelete: () => void }) { const { t } = useTranslation(); return
{expense.description}{t("expenses.added", { date: relativeDate(expense.occurredAt, locale) })}
{formatDate(expense.occurredAt, locale)}{t(categoryLabels[expense.category])}{formatCurrency(expense.amount, locale)}
; } +function ExpenseModal({ expense, onClose, organizationId, userId }: { expense?: Expense; onClose: () => void; organizationId: string; userId: string }) { const { t } = useTranslation(); const mutations = useExpenseMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as ExpenseCategory, amount: Number(data.get("amount") ?? 0), status: (expense?.status ?? "paid") as ExpenseStatus, occurredAt: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString() }; const done = () => { toast.success(expense ? t("expenses.updated") : t("expenses.created")); onClose(); }; if (expense) mutations.update.mutate({ id: expense.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate({ ...input, createdBy: userId }, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
; } +function ExpenseDetailsPage() { const { t } = useTranslation(); const { expenseId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useExpenseQuery(organizationId, expenseId); const mutations = useExpenseMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Receipt"); const expense = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error(t("bills.invalidFile")); return; } if (expenseId) mutations.upload.mutate({ id: expenseId, file, category: fileCategory }, { onSuccess: () => toast.success(t("bills.uploaded")), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await expensesService.getDocumentAsync(organizationId!, expenseId!, documentId); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "expense-document"; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; if (query.isPending) return ; if (query.error || !expense) return { void query.refetch(); }} />; return ← {t("nav.expenses")}} />
{t("expenses.amount")}{formatCurrency(expense.amount, locale)}
{t("expenses.occurred")}
{formatLongDate(expense.occurredAt, locale)}
{t("common.category")}
{t(categoryLabels[expense.category])}
{t("expenses.createdBy")}
{expense.createdBy}
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{expense.documents.length ? expense.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} { if (window.confirm(t("common.removeDocumentConfirm"))) mutations.removeDocument.mutate({ expenseId: expense.id, attachmentId: document.id }, { onSuccess: () => toast.success(t("bills.documentRemoved")), onError: (error) => toast.error(error.message) }); }}>
) : }
; } // The form mirrors a newly selected server record; this synchronization is intentional. // eslint-disable-next-line react-hooks/set-state-in-effect -function OrganizationPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const detail = useOrganizationQuery(organizationId); const budgetQuery = useBudgetQuery(organizationId); const mutations = useOrganizationMutations(organizationId); const [name, setName] = useState(""); const [budget, setBudget] = useState(""); useEffect(() => { if (detail.data) { setName(detail.data.name); setBudget(String(budgetQuery.data?.amount ?? detail.data.budget?.amount ?? "")); } }, [budgetQuery.data, detail.data]); if (!organizationId || detail.isPending) return ; if (detail.error || !detail.data) return { void detail.refetch(); }} />; const organization = detail.data; const budgetData = budgetQuery.data !== undefined ? budgetQuery.data : organization.budget; return {t("nav.members")}} />

Active workspace

{organization.name}

Created {formatLongDate(organization.createdAt, locale)}

Active
{ event.preventDefault(); mutations.update.mutate(name, { onSuccess: () => toast.success("Workspace settings saved"), onError: (error) => toast.error(error.message) }); }}>
Monthly limit{budgetData ? formatCurrency(budgetData.amount, locale) : "Not configured"}
{ event.preventDefault(); mutations.budget.mutate(Number(budget), { onSuccess: () => toast.success("Budget saved"), onError: (error) => toast.error(error.message) }); }}> setBudget(event.target.value)} />
Budget updates the dashboard
Manage members } />
{organization.members.slice(0, 3).map((member) =>
{member.username}{member.email}
)}
; } +function OrganizationPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const detail = useOrganizationQuery(organizationId); const budgetQuery = useBudgetQuery(organizationId); const mutations = useOrganizationMutations(organizationId); const [name, setName] = useState(""); const [budget, setBudget] = useState(""); useEffect(() => { if (detail.data) { setName(detail.data.name); setBudget(String(budgetQuery.data?.amount ?? detail.data.budget?.amount ?? "")); } }, [budgetQuery.data, detail.data]); if (!organizationId || detail.isPending) return ; if (detail.error || !detail.data) return { void detail.refetch(); }} />; const organization = detail.data; const budgetData = budgetQuery.data !== undefined ? budgetQuery.data : organization.budget; return {t("nav.members")}} />

{t("organization.active")}

{organization.name}

{t("organization.created", { date: formatLongDate(organization.createdAt, locale) })}

{t("common.active")}
{ event.preventDefault(); mutations.update.mutate(name, { onSuccess: () => toast.success(t("organization.settingsSaved")), onError: (error) => toast.error(error.message) }); }}>
{t("organization.monthlyLimit")}{budgetData ? formatCurrency(budgetData.amount, locale) : t("organization.notConfigured")}
{ event.preventDefault(); mutations.budget.mutate(Number(budget), { onSuccess: () => toast.success(t("organization.budgetSaved")), onError: (error) => toast.error(error.message) }); }}> setBudget(event.target.value)} />
{t("organization.dashboardUpdate")}
{t("organization.manageMembers")} } />
{organization.members.slice(0, 3).map((member) =>
{member.username}{member.email}
)}
; } function MembersPage() { const { t } = useTranslation(); @@ -233,84 +237,84 @@ function MembersPage() { onSuccess: (result) => { setInvite({ url: `${window.location.origin}/join-organization?token=${encodeURIComponent(result.token)}`, expiresAt: result.expiresAt }); setInviteOpen(false); - toast.success("Invitation created"); + toast.success(t("organization.invitationCreated")); }, - onError: (error) => toast.error(error instanceof Error ? error.message : "Unable to create the invitation."), + onError: (error) => toast.error(error instanceof Error ? error.message : t("organization.invitationError")), }); if (!organizationId || detail.isPending) return ; - if (detail.error || !detail.data) return { void detail.refetch(); }} />; + if (detail.error || !detail.data) return { void detail.refetch(); }} />; const copy = async () => { if (invite) { await navigator.clipboard.writeText(invite.url); - toast.success("Invite link copied"); + toast.success(t("organization.invitationCopied")); } }; const canChangeRole = (member: typeof detail.data.members[number]) => currentRole === "Owner" && isEditableMemberRole(member.role); const canRemove = (member: typeof detail.data.members[number]) => member.id === auth.user?.id || (currentRole === "Owner" && isEditableMemberRole(member.role)) || (currentRole === "Admin" && member.role === "Member"); const removeMember = (member: typeof detail.data.members[number]) => { const isSelf = member.id === auth.user?.id; - const confirmed = window.confirm(isSelf ? "Leave this organization?" : `Remove ${member.username} from this organization?`); + const confirmed = window.confirm(isSelf ? t("common.leaveOrganizationConfirm") : t("common.removeMemberConfirm", { name: member.username })); if (!confirmed) return; memberMutations.remove.mutate(member.id, { onSuccess: async () => { if (!isSelf) { - toast.success("Member removed"); + toast.success(t("organization.memberRemoved")); return; } const nextUser = await auth.refreshUser(); const nextOrganization = nextUser.organizations.find((organization) => organization.id !== organizationId); useOrganizationStore.getState().setSelectedOrganizationId(nextOrganization?.id ?? null); - toast.success("You left the organization"); + toast.success(t("organization.left")); navigate(nextOrganization ? "/dashboard" : "/account/create-organization", { replace: true }); }, - onError: (error) => toast.error(error instanceof Error ? error.message : "Unable to remove this member."), + onError: (error) => toast.error(error instanceof Error ? error.message : t("organization.removeError")), }); }; return setInviteOpen(true)}> {t("common.invite")} : undefined} />
-
Access overview{detail.data.members.length} people

Everyone with access to this workspace.

Protected
+
{t("organization.accessOverview")}{t("common.peopleCount", { count: detail.data.members.length })}

{t("organization.everyoneAccess")}

{t("organization.protected")}
{detail.data.members.map((member) => { const role = isKnownMemberRole(member.role) ? member.role : null; const roleClass = role ? role.toLowerCase() : "unknown"; return
{member.username}{member.email} - {role ?? "Role unavailable"} - {member.joinedAt ? `Joined ${formatLongDate(member.joinedAt, locale)}` : "Joined date unavailable"} + {role ? t(`roles.${role}`) : t("organization.roleUnavailable")} + {member.joinedAt ? t("common.joined", { date: formatLongDate(member.joinedAt, locale) }) : t("organization.joinedUnavailable")} {(canChangeRole(member) || canRemove(member)) &&
- {canChangeRole(member) && } - {canRemove(member) && } + {canChangeRole(member) && } + {canRemove(member) && }
}
; })}
- {invite &&
{ void copy(); }}>{t("common.copy")}} />
} - {inviteOpen && canInvite && setInviteOpen(false)}>
{ event.preventDefault(); const data = new FormData(event.currentTarget); createInvite.mutate({ email: String(data.get("email")), role: String(data.get("role")) as "Admin" | "Member" }); }}>
} + {invite &&
{ void copy(); }}>{t("common.copy")}} />
} + {inviteOpen && canInvite && setInviteOpen(false)}>
{ event.preventDefault(); const data = new FormData(event.currentTarget); createInvite.mutate({ email: String(data.get("email")), role: String(data.get("role")) as "Admin" | "Member" }); }}>
}
; } function AccountPage() { const { t, i18n } = useTranslation(); const auth = useAuth(); const navigate = useNavigate(); const mutations = useAccountMutations(); const user = auth.user; + const organizationId = useSelectedOrganization(); const notificationPreferences = useNotificationPreferencesQuery(organizationId); const notificationMutations = useNotificationMutations(organizationId); const [firstName, setFirstName] = useState(user?.fullName.split(" ")[0] ?? ""); const [lastName, setLastName] = useState(user?.fullName.split(" ").slice(1).join(" ") ?? ""); - const [theme, setTheme] = useState<"light" | "dark">(() => localStorage.getItem("bitfinance-v2-theme") === "dark" ? "dark" : "light"); const avatarInput = useRef(null); - useEffect(() => { document.documentElement.dataset.theme = theme === "dark" ? "dark" : ""; localStorage.setItem("bitfinance-v2-theme", theme); }, [theme]); + const { theme, setTheme } = useTheme(); const avatarInput = useRef(null); if (!user) return null; - const save = async (event: FormEvent) => { event.preventDefault(); try { await mutations.profile.mutateAsync({ firstName, lastName }); await auth.refreshUser(); toast.success("Profile saved"); } catch (error) { toast.error(error instanceof Error ? error.message : "Unable to save profile"); } }; + const save = async (event: FormEvent) => { event.preventDefault(); try { await mutations.profile.mutateAsync({ firstName, lastName }); await auth.refreshUser(); toast.success(t("account.profileSaved")); } catch (error) { toast.error(error instanceof Error ? error.message : t("account.unableSave")); } }; const upload = async (file: File) => { - if (!acceptedAvatarTypes.includes(file.type) || file.size > 2 * 1024 * 1024) { toast.error("Use a JPG, JPEG, or PNG avatar up to 2 MiB."); return; } + if (!acceptedAvatarTypes.includes(file.type) || file.size > 2 * 1024 * 1024) { toast.error(t("account.invalidAvatar")); return; } try { await mutations.avatar.mutateAsync(file); // The backend has no avatar-read endpoint, so the object URL is session-local. auth.setAvatarPreview(file); await auth.refreshUser(); - toast.success("Avatar updated"); - } catch (error) { toast.error(error instanceof Error ? error.message : "Unable to upload avatar"); } + toast.success(t("account.avatarUpdated")); + } catch (error) { toast.error(error instanceof Error ? error.message : t("account.unableUpload")); } }; - return
{user.fullName}{user.email}
{ const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ""; }} />
{ void save(event); }}>
{t("account.language")}Choose your interface language
{t("account.theme")}Choose a light or dark desk
; + return
{user.fullName}{user.email}
{ const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ""; }} />
{ void save(event); }}>
{t("account.language")}{t("account.languageDescription")}
{t("account.theme")}{t("account.themeDescription")}
{t("account.billReminderEmails")}{notificationPreferences.data?.emailAvailable ? t("account.billReminderEmailsDescription") : t("account.billReminderEmailsUpgrade")}
; } -function MorePage() { const { t } = useTranslation(); return
; } -function NotFoundPage() { return

404 / not found

That page moved.

Use the navigation to find your live finance desk.

Back to the desk
; } +function MorePage() { const { t } = useTranslation(); return
; } +function NotFoundPage() { const { t } = useTranslation(); return

{t("common.notFound")}

{t("common.pageMoved")}

{t("common.findFinanceDesk")}

{t("common.backToDesk")}
; } diff --git a/apps/frontend-v2/src/base-action-menu.tsx b/apps/frontend-v2/src/base-action-menu.tsx index dd7be00..34c9623 100644 --- a/apps/frontend-v2/src/base-action-menu.tsx +++ b/apps/frontend-v2/src/base-action-menu.tsx @@ -1,8 +1,10 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"; import { ArrowUpRight, CircleDollarSign, MoreHorizontal, RotateCcw, Settings2 } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; export function BaseActionMenu({ onEdit, onPaid, onDelete, detailHref, canPay = false }: { onEdit: () => void; onPaid?: () => void; onDelete: () => void; detailHref?: string; canPay?: boolean }) { const navigate = useNavigate(); - return Edit details{canPay && onPaid && Mark as paid}{detailHref && navigate(detailHref)}> View details} Delete; + const { t } = useTranslation(); + return {t("common.edit")} {t("common.details")}{canPay && onPaid && {t("bills.markPaid")}}{detailHref && navigate(detailHref)}> {t("common.viewDetails")}} {t("common.delete")}; } diff --git a/apps/frontend-v2/src/format.ts b/apps/frontend-v2/src/format.ts index 9f1d86e..33e65bb 100644 --- a/apps/frontend-v2/src/format.ts +++ b/apps/frontend-v2/src/format.ts @@ -1,4 +1,5 @@ import { format, formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; export function formatCurrency(value: number, locale = "en-US") { return new Intl.NumberFormat(locale, { style: "currency", currency: locale === "pt-BR" ? "BRL" : "USD", maximumFractionDigits: 2 }).format(value); @@ -12,8 +13,8 @@ export function formatLongDate(value: string, locale = "en-US") { return new Intl.DateTimeFormat(locale, { month: "long", day: "numeric", year: "numeric" }).format(new Date(value)); } -export function relativeDate(value: string) { - return formatDistanceToNow(new Date(value), { addSuffix: true }); +export function relativeDate(value: string, locale = "en-US") { + return formatDistanceToNow(new Date(value), { addSuffix: true, locale: locale === "pt-BR" ? ptBR : undefined }); } export function inputDate(value: string) { diff --git a/apps/frontend-v2/src/hooks/use-queries.ts b/apps/frontend-v2/src/hooks/use-queries.ts index d940f43..ef61f58 100644 --- a/apps/frontend-v2/src/hooks/use-queries.ts +++ b/apps/frontend-v2/src/hooks/use-queries.ts @@ -7,6 +7,7 @@ import { dashboardService } from "../api/dashboard/dashboard.service"; import { expensesService } from "../api/expenses/expenses.service"; import { healthService } from "../api/health/health.service"; import { organizationsService } from "../api/organizations/organizations.service"; +import { notificationsService } from "../api/notifications/notifications.service"; import type { BillInput, BillListFilters } from "../api/bills/bills.types"; import type { ExpenseInput, ExpenseListFilters } from "../api/expenses/expenses.types"; import type { EditableOrganizationMemberRole } from "../api/organizations/organizations.service"; @@ -28,6 +29,18 @@ export function useBillsQuery(filters: BillListFilters | null) { return useQuery export function useBillQuery(organizationId: string | null, billId?: string) { return useQuery({ queryKey: queryKeys.bills.detail(organizationId ?? "", billId ?? ""), queryFn: () => billsService.getAsync(organizationId!, billId!), enabled: Boolean(organizationId && billId) }); } export function useExpensesQuery(filters: ExpenseListFilters | null) { return useQuery({ queryKey: filters ? queryKeys.expenses.list(filters.organizationId, filters.page, filters.pageSize, filters.from, filters.to) : ["expenses", "disabled"], queryFn: () => expensesService.listAsync(filters!), enabled: Boolean(filters) }); } export function useExpenseQuery(organizationId: string | null, expenseId?: string) { return useQuery({ queryKey: queryKeys.expenses.detail(organizationId ?? "", expenseId ?? ""), queryFn: () => expensesService.getAsync(organizationId!, expenseId!), enabled: Boolean(organizationId && expenseId) }); } +export function useNotificationsQuery(organizationId: string | null, enabled = true) { return useQuery({ queryKey: queryKeys.notifications.list(organizationId ?? ""), queryFn: () => notificationsService.listAsync(organizationId!), enabled: Boolean(organizationId) && enabled, refetchInterval: 60_000 }); } +export function useNotificationUnreadCountQuery(organizationId: string | null) { return useQuery({ queryKey: queryKeys.notifications.unread(organizationId ?? ""), queryFn: () => notificationsService.unreadCountAsync(organizationId!), enabled: Boolean(organizationId), refetchInterval: 60_000 }); } +export function useNotificationPreferencesQuery(organizationId: string | null) { return useQuery({ queryKey: queryKeys.notifications.preferences(organizationId ?? ""), queryFn: () => notificationsService.getPreferencesAsync(organizationId!), enabled: Boolean(organizationId) }); } +export function useNotificationMutations(organizationId: string | null) { + const client = useQueryClient(); + const invalidate = () => void client.invalidateQueries({ queryKey: queryKeys.notifications.all }); + return { + markRead: useMutation({ mutationFn: (notificationId: string) => notificationsService.markReadAsync(organizationId!, notificationId), onSuccess: invalidate }), + markAllRead: useMutation({ mutationFn: () => notificationsService.markAllReadAsync(organizationId!), onSuccess: invalidate }), + updatePreferences: useMutation({ mutationFn: (enabled: boolean) => notificationsService.updatePreferencesAsync(organizationId!, enabled), onSuccess: invalidate }), + }; +} export function useOrganizationMutations(organizationId: string | null) { const client = useQueryClient(); diff --git a/apps/frontend-v2/src/hooks/use-theme.ts b/apps/frontend-v2/src/hooks/use-theme.ts new file mode 100644 index 0000000..e7866a9 --- /dev/null +++ b/apps/frontend-v2/src/hooks/use-theme.ts @@ -0,0 +1,29 @@ +import { useCallback, useSyncExternalStore } from "react"; + +export type Theme = "light" | "dark"; + +const STORAGE_KEY = "bitfinance-v2-theme"; +const CHANGE_EVENT = "bitfinance-theme-change"; + +function getSnapshot(): Theme { + return localStorage.getItem(STORAGE_KEY) === "dark" ? "dark" : "light"; +} + +function subscribe(callback: () => void) { + window.addEventListener(CHANGE_EVENT, callback); + window.addEventListener("storage", callback); + return () => { + window.removeEventListener(CHANGE_EVENT, callback); + window.removeEventListener("storage", callback); + }; +} + +export function useTheme() { + const theme = useSyncExternalStore(subscribe, getSnapshot); + const setTheme = useCallback((next: Theme) => { + localStorage.setItem(STORAGE_KEY, next); + document.documentElement.dataset.theme = next === "dark" ? "dark" : ""; + window.dispatchEvent(new Event(CHANGE_EVENT)); + }, []); + return { theme, setTheme } as const; +} diff --git a/apps/frontend-v2/src/i18n.ts b/apps/frontend-v2/src/i18n.ts index 4489933..6524221 100644 --- a/apps/frontend-v2/src/i18n.ts +++ b/apps/frontend-v2/src/i18n.ts @@ -1,30 +1,64 @@ import i18next from "i18next"; import { initReactI18next } from "react-i18next"; -const resources = { +export const resources = { "en-US": { translation: { + meta: { title: "BitFinance — finance desk", description: "BitFinance — a clearer view of your money." }, nav: { overview: "Overview", bills: "Bills", expenses: "Expenses", organization: "Organization", members: "Members", account: "Account" }, - common: { save: "Save changes", cancel: "Cancel", close: "Close", add: "Add", edit: "Edit", delete: "Delete", search: "Search", all: "All", today: "Today", viewAll: "View all", loading: "Loading", noResults: "No results", clearFilters: "Clear filters", signIn: "Sign in", signUp: "Create account", continue: "Continue", invite: "Invite member", copy: "Copy invite link" }, - home: { eyebrow: "A clearer view of your money", title: "Make room for the life you’re planning.", body: "BitFinance brings bills, spending, and shared decisions into one calm workspace — so your next move is always visible.", cta: "Open the live desk", secondary: "See how it works", signal: "Built for real-life money moments" }, - auth: { signInTitle: "Welcome back", signInBody: "Your money desk is ready for today’s decisions.", signUpTitle: "Start with a clearer picture", signUpBody: "Create a workspace for the people and plans that matter.", email: "Email address", password: "Password", firstName: "First name", lastName: "Last name", noAccount: "New to BitFinance?", haveAccount: "Already have an account?" }, - dashboard: { eyebrow: "Selected period", title: "Good morning", body: "Here’s the shape of your money in the selected period.", budget: "Monthly budget", spent: "Spent so far", remaining: "Available", upcoming: "Upcoming", flow: "Cash-flow map", flowBody: "Your selected period, plotted as decisions instead of noise.", upcomingTitle: "Coming up", recentTitle: "Recent spending", categories: "Where it goes", onTrack: "You’re on track", setBudget: "Set a budget" }, - bills: { eyebrow: "Scheduled money", title: "Bills", body: "Keep every commitment visible before it becomes urgent.", add: "Add bill", total: "Total scheduled", due: "Due soon", paid: "Paid this month", search: "Search bills", empty: "No bills match these filters." }, - expenses: { eyebrow: "Money already moved", title: "Expenses", body: "A lightweight record of what happened — and what it means.", add: "Add expense", total: "Total spent", transactions: "transactions", search: "Search expenses", empty: "No expenses match these filters." }, - organization: { eyebrow: "Shared workspace", title: "Organization", body: "Set the rules and context behind the numbers.", settings: "Workspace settings", budget: "Monthly budget", members: "People with access", memberBody: "Invite the people who help make the calls.", name: "Workspace name", membersTitle: "Members", membersBody: "A simple view of who is part of this money desk." }, - account: { eyebrow: "Your preferences", title: "Account", body: "Make the desk feel like yours.", profile: "Profile", appearance: "Appearance", language: "Language", theme: "Theme", signOut: "Sign out", reset: "Session preferences" }, + common: { + save: "Save changes", cancel: "Cancel", close: "Close", add: "Add", edit: "Edit", delete: "Delete", search: "Search", all: "All", today: "Today", viewAll: "View all", loading: "Loading", noResults: "No results", clearFilters: "Clear filters", signIn: "Sign in", signUp: "Create account", continue: "Continue", invite: "Invite member", copy: "Copy invite link", update: "Update", open: "Open", download: "Download", remove: "Remove", apply: "Apply", previous: "Previous", next: "Next", actions: "Actions", more: "More", details: "details", viewDetails: "View details", backHome: "Back to home", closeMenu: "Close menu", openMenu: "Open menu", notifications: "Notifications", selectOrganization: "Select organization", primaryNavigation: "Primary navigation", mobileNavigation: "Mobile navigation", workspace: "Workspace", workspaceSettings: "Workspace settings", cashFlow: "Cash flow", healthyThisMonth: "Healthy this month", liveWorkspace: "Live workspace", financeDesk: "finance desk", active: "Active", protected: "Protected", profile: "Profile", appearance: "Appearance", language: "Language", theme: "Theme", english: "English", portuguese: "Português", light: "Light", dark: "Dark", people: "people", peopleCount_one: "{{count}} person", peopleCount_other: "{{count}} people", transactions: "transactions", transactionCount_one: "across {{count}} transaction", transactionCount_other: "across {{count}} transactions", commitments: "commitments", commitmentCount_one: "{{count}} commitment", commitmentCount_other: "{{count}} commitments", statuses: "statuses", types: "types", amount: "Amount", category: "Category", description: "Description", date: "Date", frequency: "Frequency", schedule: "Schedule", installments: "Installments (optional)", emailAddress: "Email address", role: "Role", documentCategory: "Document category", attachments: "Attachments", addFile: "Add file", removeFile: "Remove {{name}}", selectPeriod: "Select dashboard period", choosePeriod: "Choose a period", periodUpdated: "Dashboard data updates after applying.", from: "From", to: "To", endDateError: "The end date must be on or after the start date.", thisMonth: "This month", moreActions: "More actions", pageMoved: "That page moved.", findFinanceDesk: "Use the navigation to find your live finance desk.", backToDesk: "Back to the desk", notFound: "404 / not found", oneTime: "One time", added: "Added {{date}}", joined: "Joined {{date}}", expires: "Expires {{date}}", created: "Created {{date}}", peopleWithAccess: "People with access", connectedRoutes: "{{count}} routes connected to one calm workspace", acrossTransactions: "across {{count}} transactions", commitmentCount: "{{count}} commitments", titleWithName: "{{title}}, {{name}}", installmentCount: "{{current}}/{{total}} installment", removeDocumentConfirm: "Remove this document?", deleteBillConfirm: "Delete this bill?", deleteExpenseConfirm: "Delete this expense?", removeAvatarConfirm: "Remove your avatar?", signOutAllConfirm: "Sign out from every device?", leaveOrganizationConfirm: "Leave this organization?", stopFutureBillsConfirm: "Stop future bills? Existing occurrences remain.", removeMemberConfirm: "Remove {{name}} from this organization?" }, + home: { + eyebrow: "A clearer view of your money", title: "Make room for the life you’re planning.", body: "BitFinance brings bills, spending, and shared decisions into one calm workspace — so your next move is always visible.", cta: "Open the live desk", secondary: "See how it works", signal: "Built for real-life money moments", routes: "{{count}} routes connected to one calm workspace", cashFlow: "Cash flow", available: "Available", liveData: "Live workspace data", upcomingBills: "Upcoming bills", spentThisMonth: "Spent this month", paymentCleared: "payment cleared", readyNext: "Ready for the next decision", liveContext: "with live account context", nextStep: "Every number has a next step.", committed: "Know what’s committed", committedBody: "See upcoming obligations before they crowd out the choices you actually want to make.", pattern: "Notice the pattern", patternBody: "Turn a pile of transactions into a rhythm you can talk about together.", shared: "Keep it shared", sharedBody: "Invite the people who need context, without turning your home into a spreadsheet.", footer: "© 2026 BitFinance. A clearer view of your money." }, + auth: { signInTitle: "Welcome back", signInBody: "Your money desk is ready for today’s decisions.", signUpTitle: "Start with a clearer picture", signUpBody: "Create a workspace for the people and plans that matter.", email: "Email address", password: "Password", firstName: "First name", lastName: "Last name", noAccount: "New to BitFinance?", haveAccount: "Already have an account?", protectedSession: "Your session stays private on this device.", minimumPassword: "Your account starts with an eight-character minimum password.", signInStep: "sign in", getStarted: "get started", serverData: "Live account data stays on the server", validCredentials: "Use a valid email and password.", validRegistration: "Use a valid email and a password with at least 8 characters.", welcomeBack: "Welcome back", accountCreated: "Account created", unableContinue: "Unable to continue." }, + join: { eyebrow: "Invitation / live", title: "Join this organization", missingTitle: "Invitation link missing", body: "Accept the invitation after signing in to add this organization to your workspace.", missingBody: "Ask the sender for a fresh invitation link.", joined: "You joined the organization", invalid: "This invitation cannot be used." }, + createOrganization: { eyebrow: "New workspace / 01", title: "Create a money desk", body: "Give the workspace a name. You can invite people and set a budget from the organization area.", workspaceName: "Workspace name", creating: "Creating", preparing: "Preparing your workspace", unable: "Unable to create the workspace.", created: "Organization created" }, + dashboard: { eyebrow: "Selected period", title: "Good morning", body: "Here’s the shape of your money in the selected period.", budget: "Monthly budget", spent: "Spent so far", remaining: "Available", upcoming: "Upcoming", flow: "Cash-flow map", flowBody: "Your selected period, plotted as decisions instead of noise.", upcomingTitle: "Coming up", recentTitle: "Recent spending", categories: "Where it goes", onTrack: "You’re on track", setBudget: "Set a budget", greetingFallback: "there", createOrganization: "Create an organization first", needsOrganization: "Your dashboard needs an organization context.", createWorkspace: "Create workspace", notSet: "Not set", dataUnavailable: "Some dashboard data could not be loaded.", committedMoney: "Your committed money is {{amount}} this period.", budgetUsed: "of budget used", configureLimit: "Configure a monthly limit", currentLimit: "Current month limit", noBudget: "No budget configured", availableToSpend: "Available to spend", upcomingUnavailable: "Upcoming bills are unavailable.", recentUnavailable: "Recent expenses are unavailable.", nextDecisions: "The next decisions in line", noUpcoming: "No upcoming bills", nextCommitments: "Your next commitments will appear here.", selectedPeriodRead: "A small read on the selected period", foodHome: "Food & home", transport: "Transport", personal: "Personal", keepCommitment: "Keep a commitment visible", recordExpense: "Record what just happened", monthBoundary: "Give the month a boundary", commitments: "commitments", timelineCommitments: "commitments", timelineMoved: "moved", noMovement: "No movement in this period", timelineEmpty: "Bills and expenses will appear on the timeline." }, + bills: { eyebrow: "Scheduled money", title: "Bills", body: "Keep every commitment visible before it becomes urgent.", add: "Add bill", total: "Total scheduled", due: "Due soon", paid: "Paid this month", search: "Search bills", empty: "No bills match these filters.", selectOrganization: "Select an organization", scoped: "Bills are scoped to an organization.", commitment: "Commitment", dueDate: "Due date", type: "Type", status: "Status", allStatuses: "All statuses", allTypes: "All types", recurring: "Recurring", installments: "Installments", updated: "Bill updated", created: "Bill created", removed: "Bill removed", markedPaid: "Bill marked as paid", markPaid: "Mark as paid", edit: "Edit bill", formDescription: "Give this commitment a name, an amount, and a due date.", oneTime: "One time", installment: "Installment", weekly: "Weekly", monthly: "Monthly", annually: "Annually", invalidFile: "Use a PDF, JPG, PNG, DOC, or DOCX file up to 10 MiB.", uploaded: "Document uploaded", documentRemoved: "Document removed", notFound: "Bill not found.", detail: "Bill detail", amountDue: "Amount due", stopFuture: "Stop future bills", futureStopped: "Future bills stopped", attachmentDescription: "Open a document in a new tab, or save a copy with download.", noAttachments: "No attachments yet", receiptHint: "Add a receipt or boleto when you have one." }, + expenses: { eyebrow: "Money already moved", title: "Expenses", body: "A lightweight record of what happened — and what it means.", add: "Add expense", total: "Total spent", transactions: "transactions", search: "Search expenses", empty: "No expenses match these filters.", selectOrganization: "Select an organization", scoped: "Expenses are scoped to an organization.", average: "Average", localFilters: "Search and status filter the expenses shown on this page.", expense: "Expense", date: "Date", category: "Category", amount: "Amount", status: "Status", allStatuses: "All statuses", paid: "Paid", pending: "Pending", cancelled: "Cancelled", added: "Added {{date}}", updated: "Expense updated", created: "Expense added", removed: "Expense removed", edit: "Edit expense", formDescription: "Note what was spent and when it happened.", notFound: "Expense not found.", detail: "Expense detail", occurred: "Occurred", createdBy: "Created by", attachmentDescription: "Download a copy of an attached document.", noAttachments: "No attachments yet", receiptHint: "Add a receipt when you have one." }, + organization: { eyebrow: "Shared workspace", title: "Organization", body: "Set the rules and context behind the numbers.", settings: "Workspace settings", budget: "Monthly budget", members: "People with access", memberBody: "Invite the people who help make the calls.", name: "Workspace name", membersTitle: "Members", membersBody: "A simple view of who is part of this money desk.", active: "Active workspace", created: "Created {{date}}", onlyEditable: "Rename the workspace shown across your desk.", settingsSaved: "Workspace settings saved", boundary: "A boundary for the month, not a judgment.", monthlyLimit: "Monthly limit", notConfigured: "Not configured", budgetSaved: "Budget saved", monthlyBudget: "Monthly budget", dashboardUpdate: "Budget updates the dashboard", manageMembers: "Manage members", accessOverview: "Access overview", everyoneAccess: "Everyone with access to this workspace.", protected: "Protected", invitationCreated: "Invitation created", invitationCopied: "Invite link copied", invitationError: "Unable to create the invitation.", memberRemoved: "Member removed", left: "You left the organization", invitationReady: "Invitation ready", invitationDescription: "The invitation is valid for 24 hours and does not add a member until accepted.", invitationLink: "Invitation link", email: "Email address", role: "Role", roleFor: "Role for", roleUpdated: "Member role updated", roleError: "Unable to update this member's role.", leave: "Leave organization", remove: "Remove", removeError: "Unable to remove this member.", roleUnavailable: "Role unavailable", joinedUnavailable: "Joined date unavailable", membersUnavailable: "Members could not be loaded.", notFound: "Organization not found." }, + account: { eyebrow: "Your preferences", title: "Account", body: "Make the desk feel like yours.", profile: "Profile", appearance: "Appearance", language: "Language", theme: "Theme", signOut: "Sign out", reset: "Session preferences", profileDescription: "The name and email shown to your workspace.", changeAvatar: "Change avatar", removeAvatar: "Remove avatar", languageDescription: "Choose your interface language", themeDescription: "Choose a light or dark desk", billReminderEmails: "Bill reminder emails", billReminderEmailsDescription: "Email me before, on, and after a bill is due", billReminderEmailsUpgrade: "Available on Basic and Premium plans", signOutAll: "Sign out all devices", profileSaved: "Profile saved", unableSave: "Unable to save profile", invalidAvatar: "Use a JPG, JPEG, or PNG avatar up to 2 MiB.", avatarUpdated: "Avatar updated", unableUpload: "Unable to upload avatar", avatarRemoved: "Avatar removed" }, + notifications: { currentOrganization: "For the selected organization", markAllRead: "Mark all read", unreadCount: "{{count}} unread notifications", empty: "Nothing new here yet.", billDueSoon: { title: "Bill due soon", body: "{{billDescription}} is due in three days." }, billDueToday: { title: "Bill due today", body: "{{billDescription}} is due today." }, billOverdue: { title: "Bill overdue", body: "{{billDescription}} is now overdue." }, memberJoined: { title: "Member joined", body: "{{memberName}} joined the organization." }, memberRoleChanged: { title: "Member role changed", body: "{{memberName}} is now {{newRole}}." }, memberRemoved: { title: "Member removed", body: "{{memberName}} was removed from the organization." } }, + more: { eyebrow: "More / workspace", title: "More", body: "The useful edges of your finance desk", budgetSettings: "Budget and workspace settings", access: "People with access", profilePreferences: "Profile and preferences" }, + errors: { attention: "Something needs attention", tryAgain: "Try again", requestCanceled: "Request canceled.", validation: "Please check the highlighted fields." }, + api: { healthFailed: "Health check failed with {{status}}", account: { updateProfile: "Unable to update your profile.", uploadAvatar: "Unable to upload your avatar.", removeAvatar: "Unable to remove your avatar." }, auth: { createAccount: "Unable to create your account.", signIn: "Unable to sign in.", restoreSession: "Unable to restore your session.", signOut: "Unable to sign out.", signOutAll: "Unable to sign out all sessions.", loadAccount: "Unable to load your account." }, bills: { load: "Unable to load bills.", loadOne: "Unable to load this bill.", create: "Unable to create the bill.", update: "Unable to update the bill.", delete: "Unable to delete the bill.", uploadDocument: "Unable to upload the bill document.", openDocument: "Unable to open the bill document.", prepareDownload: "Unable to prepare the download.", removeDocument: "Unable to remove the bill document.", stopFuture: "Unable to stop future bills." }, dashboard: { summary: "Unable to load the dashboard summary.", upcoming: "Unable to load upcoming bills.", recent: "Unable to load recent expenses." }, expenses: { load: "Unable to load expenses.", loadOne: "Unable to load this expense.", create: "Unable to create the expense.", update: "Unable to update the expense.", delete: "Unable to delete the expense.", uploadDocument: "Unable to upload the expense document.", openDocument: "Unable to open the expense document.", removeDocument: "Unable to remove the expense document." }, organizations: { load: "Unable to load organizations.", loadOne: "Unable to load this organization.", create: "Unable to create the organization.", update: "Unable to update the organization.", loadBudget: "Unable to load the budget.", saveBudget: "Unable to save the budget.", createInvitation: "Unable to create the invitation.", updateRole: "Unable to update this member's role.", removeMember: "Unable to remove this member.", join: "Unable to join the organization." }, notifications: { load: "Unable to load notifications.", markRead: "Unable to update notifications.", loadPreferences: "Unable to load notification preferences.", savePreferences: "Unable to save notification preferences." } }, + types: { housing: "Housing", utilities: "Utilities", food: "Food", transportation: "Transport", healthcare: "Healthcare", subscriptions: "Subscriptions", education: "Education", insurance: "Insurance", personal: "Personal", taxes: "Taxes", miscellaneous: "Misc", travel: "Travel", gifts: "Gifts", pets: "Pets", recurring: "Recurring", installment: "Installment", oneTime: "One time" }, + statuses: { upcoming: "Upcoming", due: "Due", overdue: "Overdue", paid: "Paid", pending: "Pending", cancelled: "Cancelled", unknown: "Unknown" }, + roles: { Owner: "Owner", Admin: "Admin", Member: "Member" }, + documents: { Invoice: "Invoice", Receipt: "Receipt", Boleto: "Boleto", Contract: "Contract", Other: "Other" }, } }, "pt-BR": { translation: { + meta: { title: "BitFinance — mesa financeira", description: "BitFinance — uma visão mais clara do seu dinheiro." }, nav: { overview: "Visão geral", bills: "Contas", expenses: "Despesas", organization: "Organização", members: "Membros", account: "Conta" }, - common: { save: "Salvar alterações", cancel: "Cancelar", close: "Fechar", add: "Adicionar", edit: "Editar", delete: "Excluir", search: "Buscar", all: "Todos", today: "Hoje", viewAll: "Ver tudo", loading: "Carregando", noResults: "Sem resultados", clearFilters: "Limpar filtros", signIn: "Entrar", signUp: "Criar conta", continue: "Continuar", invite: "Convidar membro", copy: "Copiar convite" }, - home: { eyebrow: "Uma visão mais clara do seu dinheiro", title: "Abra espaço para a vida que você está planejando.", body: "O BitFinance reúne contas, gastos e decisões compartilhadas em um só lugar calmo — para o próximo passo estar sempre visível.", cta: "Abrir a mesa ao vivo", secondary: "Ver como funciona", signal: "Feito para os momentos reais do dinheiro" }, - auth: { signInTitle: "Bem-vinda de volta", signInBody: "Sua mesa financeira está pronta para as decisões de hoje.", signUpTitle: "Comece com uma visão mais clara", signUpBody: "Crie um espaço para as pessoas e planos que importam.", email: "E-mail", password: "Senha", firstName: "Nome", lastName: "Sobrenome", noAccount: "Ainda não usa o BitFinance?", haveAccount: "Já possui uma conta?" }, - dashboard: { eyebrow: "Período selecionado", title: "Bom dia", body: "Este é o desenho do seu dinheiro no período selecionado.", budget: "Orçamento mensal", spent: "Gasto até agora", remaining: "Disponível", upcoming: "A seguir", flow: "Mapa do fluxo", flowBody: "Seu período selecionado, plotado como decisões em vez de ruído.", upcomingTitle: "A seguir", recentTitle: "Gastos recentes", categories: "Para onde vai", onTrack: "Você está no caminho", setBudget: "Definir orçamento" }, - bills: { eyebrow: "Dinheiro programado", title: "Contas", body: "Mantenha cada compromisso visível antes que vire urgência.", add: "Adicionar conta", total: "Total programado", due: "Vence em breve", paid: "Pago neste mês", search: "Buscar contas", empty: "Nenhuma conta corresponde a estes filtros." }, - expenses: { eyebrow: "Dinheiro que já saiu", title: "Despesas", body: "Um registro leve do que aconteceu — e do que isso significa.", add: "Adicionar despesa", total: "Total gasto", transactions: "transações", search: "Buscar despesas", empty: "Nenhuma despesa corresponde a estes filtros." }, - organization: { eyebrow: "Espaço compartilhado", title: "Organização", body: "Defina as regras e o contexto por trás dos números.", settings: "Configurações do espaço", budget: "Orçamento mensal", members: "Pessoas com acesso", memberBody: "Convide quem ajuda a tomar as decisões.", name: "Nome do espaço", membersTitle: "Membros", membersBody: "Uma visão simples de quem faz parte desta mesa financeira." }, - account: { eyebrow: "Suas preferências", title: "Conta", body: "Deixe a mesa com a sua cara.", profile: "Perfil", appearance: "Aparência", language: "Idioma", theme: "Tema", signOut: "Sair", reset: "Preferências da sessão" }, + common: { + save: "Salvar alterações", cancel: "Cancelar", close: "Fechar", add: "Adicionar", edit: "Editar", delete: "Excluir", search: "Buscar", all: "Todos", today: "Hoje", viewAll: "Ver tudo", loading: "Carregando", noResults: "Sem resultados", clearFilters: "Limpar filtros", signIn: "Entrar", signUp: "Criar conta", continue: "Continuar", invite: "Convidar membro", copy: "Copiar convite", update: "Atualizar", open: "Abrir", download: "Baixar", remove: "Remover", apply: "Aplicar", previous: "Anterior", next: "Próxima", actions: "Ações", more: "Mais", details: "detalhes", viewDetails: "Ver detalhes", backHome: "Voltar ao início", closeMenu: "Fechar menu", openMenu: "Abrir menu", notifications: "Notificações", selectOrganization: "Selecionar organização", primaryNavigation: "Navegação principal", mobileNavigation: "Navegação móvel", workspace: "Espaço de trabalho", workspaceSettings: "Configurações do espaço", cashFlow: "Fluxo financeiro", healthyThisMonth: "Saudável neste mês", liveWorkspace: "Espaço ao vivo", financeDesk: "mesa financeira", active: "Ativo", protected: "Protegido", profile: "Perfil", appearance: "Aparência", language: "Idioma", theme: "Tema", english: "English", portuguese: "Português", light: "Claro", dark: "Escuro", people: "pessoas", peopleCount_one: "{{count}} pessoa", peopleCount_other: "{{count}} pessoas", transactions: "transações", transactionCount_one: "em {{count}} transação", transactionCount_other: "em {{count}} transações", commitments: "compromissos", commitmentCount_one: "{{count}} compromisso", commitmentCount_other: "{{count}} compromissos", statuses: "status", types: "tipos", amount: "Valor", category: "Categoria", description: "Descrição", date: "Data", frequency: "Frequência", schedule: "Programação", installments: "Parcelas (opcional)", emailAddress: "Endereço de e-mail", role: "Função", documentCategory: "Categoria do documento", attachments: "Anexos", addFile: "Adicionar arquivo", removeFile: "Remover {{name}}", selectPeriod: "Selecionar período do painel", choosePeriod: "Escolha um período", periodUpdated: "Os dados do painel serão atualizados após a aplicação.", from: "De", to: "Até", endDateError: "A data final deve ser igual ou posterior à data inicial.", thisMonth: "Este mês", moreActions: "Mais ações", pageMoved: "Essa página mudou.", findFinanceDesk: "Use a navegação para encontrar sua mesa financeira.", backToDesk: "Voltar para a mesa", notFound: "404 / não encontrado", oneTime: "Avulsa", added: "Adicionada {{date}}", joined: "Entrou em {{date}}", expires: "Expira em {{date}}", created: "Criada em {{date}}", peopleWithAccess: "Pessoas com acesso", connectedRoutes: "{{count}} rotas conectadas a um só espaço tranquilo", acrossTransactions: "em {{count}} transações", commitmentCount: "{{count}} compromissos", titleWithName: "{{title}}, {{name}}", installmentCount: "{{current}}/{{total}} parcela", removeDocumentConfirm: "Remover este documento?", deleteBillConfirm: "Excluir esta conta?", deleteExpenseConfirm: "Excluir esta despesa?", removeAvatarConfirm: "Remover seu avatar?", signOutAllConfirm: "Sair de todos os dispositivos?", leaveOrganizationConfirm: "Sair desta organização?", stopFutureBillsConfirm: "Parar contas futuras? As ocorrências existentes permanecerão.", removeMemberConfirm: "Remover {{name}} desta organização?" }, + home: { + eyebrow: "Uma visão mais clara do seu dinheiro", title: "Abra espaço para a vida que você está planejando.", body: "O BitFinance reúne contas, gastos e decisões compartilhadas em um só espaço tranquilo — para o próximo passo estar sempre visível.", cta: "Abrir a mesa ao vivo", secondary: "Ver como funciona", signal: "Feito para os momentos reais do dinheiro", routes: "{{count}} rotas conectadas a um só espaço tranquilo", cashFlow: "Fluxo financeiro", available: "Disponível", liveData: "Dados financeiros ao vivo", upcomingBills: "Próximas contas", spentThisMonth: "Gasto neste mês", paymentCleared: "pagamento compensado", readyNext: "Pronto para a próxima decisão", liveContext: "com o contexto da conta ao vivo", nextStep: "Todo número aponta para um próximo passo.", committed: "Saiba o que está comprometido", committedBody: "Veja as obrigações futuras antes que elas limitem as escolhas que você realmente quer fazer.", pattern: "Perceba o padrão", patternBody: "Transforme uma pilha de transações em um ritmo que vocês possam conversar juntos.", shared: "Mantenha tudo compartilhado", sharedBody: "Convide quem precisa de contexto sem transformar sua casa em uma planilha.", footer: "© 2026 BitFinance. Uma visão mais clara do seu dinheiro." }, + auth: { signInTitle: "Bem-vinda de volta", signInBody: "Sua mesa financeira está pronta para as decisões de hoje.", signUpTitle: "Comece com uma visão mais clara", signUpBody: "Crie um espaço para as pessoas e planos que importam.", email: "E-mail", password: "Senha", firstName: "Nome", lastName: "Sobrenome", noAccount: "Ainda não usa o BitFinance?", haveAccount: "Já possui uma conta?", protectedSession: "Sua sessão permanece privada neste dispositivo.", minimumPassword: "Sua conta começa com uma senha de no mínimo oito caracteres.", signInStep: "entrar", getStarted: "começar", serverData: "Os dados da sua conta ficam no servidor", validCredentials: "Use um e-mail e uma senha válidos.", validRegistration: "Use um e-mail válido e uma senha com pelo menos 8 caracteres.", welcomeBack: "Bem-vinda de volta", accountCreated: "Conta criada", unableContinue: "Não foi possível continuar." }, + join: { eyebrow: "Convite / ao vivo", title: "Entrar nesta organização", missingTitle: "Link de convite ausente", body: "Aceite o convite depois de entrar para adicionar esta organização ao seu espaço de trabalho.", missingBody: "Peça ao remetente um novo link de convite.", joined: "Você entrou na organização", invalid: "Este convite não pode ser usado." }, + createOrganization: { eyebrow: "Novo espaço / 01", title: "Crie uma mesa financeira", body: "Dê um nome ao espaço. Você poderá convidar pessoas e definir um orçamento na área da organização.", workspaceName: "Nome do espaço", creating: "Criando", preparing: "Preparando seu espaço", unable: "Não foi possível criar o espaço.", created: "Organização criada" }, + dashboard: { eyebrow: "Período selecionado", title: "Bom dia", body: "Este é o desenho do seu dinheiro no período selecionado.", budget: "Orçamento mensal", spent: "Gasto até agora", remaining: "Disponível", upcoming: "A seguir", flow: "Mapa do fluxo", flowBody: "Seu período selecionado, organizado como decisões em vez de ruído.", upcomingTitle: "A seguir", recentTitle: "Gastos recentes", categories: "Para onde vai", onTrack: "Você está no caminho", setBudget: "Definir orçamento", greetingFallback: "aí", createOrganization: "Crie uma organização primeiro", needsOrganization: "Seu painel precisa do contexto de uma organização.", createWorkspace: "Criar espaço", notSet: "Não definido", dataUnavailable: "Não foi possível carregar alguns dados do painel.", committedMoney: "Seu dinheiro comprometido é {{amount}} neste período.", budgetUsed: "do orçamento usado", configureLimit: "Configure um limite mensal", currentLimit: "Limite do mês atual", noBudget: "Nenhum orçamento configurado", availableToSpend: "Disponível para gastar", upcomingUnavailable: "As próximas contas não estão disponíveis.", recentUnavailable: "As despesas recentes não estão disponíveis.", nextDecisions: "As próximas decisões na fila", noUpcoming: "Nenhuma conta próxima", nextCommitments: "Seus próximos compromissos aparecerão aqui.", selectedPeriodRead: "Uma leitura breve do período selecionado", foodHome: "Alimentação e casa", transport: "Transporte", personal: "Pessoal", keepCommitment: "Mantenha um compromisso visível", recordExpense: "Registre o que acabou de acontecer", monthBoundary: "Dê um limite ao mês", commitments: "compromissos", timelineCommitments: "compromissos", timelineMoved: "movimentações", noMovement: "Nenhuma movimentação neste período", timelineEmpty: "Contas e despesas aparecerão na linha do tempo." }, + bills: { eyebrow: "Dinheiro programado", title: "Contas", body: "Mantenha cada compromisso visível antes que vire urgência.", add: "Adicionar conta", total: "Total programado", due: "Vence em breve", paid: "Pago neste mês", search: "Buscar contas", empty: "Nenhuma conta corresponde a estes filtros.", selectOrganization: "Selecione uma organização", scoped: "As contas pertencem a uma organização.", commitment: "Compromisso", dueDate: "Vencimento", type: "Tipo", status: "Status", allStatuses: "Todos os status", allTypes: "Todos os tipos", recurring: "Recorrente", installments: "Parcelas", updated: "Conta atualizada", created: "Conta criada", removed: "Conta removida", markedPaid: "Conta marcada como paga", markPaid: "Marcar como paga", edit: "Editar conta", formDescription: "Dê um nome, um valor e um vencimento a este compromisso.", oneTime: "Avulsa", installment: "Parcelada", weekly: "Semanal", monthly: "Mensal", annually: "Anual", invalidFile: "Use um arquivo PDF, JPG, PNG, DOC ou DOCX de até 10 MiB.", uploaded: "Documento enviado", documentRemoved: "Documento removido", notFound: "Conta não encontrada.", detail: "Detalhes da conta", amountDue: "Valor devido", stopFuture: "Parar contas futuras", futureStopped: "Contas futuras interrompidas", attachmentDescription: "Abra um documento em uma nova aba ou salve uma cópia com o download.", noAttachments: "Nenhum anexo ainda", receiptHint: "Adicione um recibo ou boleto quando tiver um." }, + expenses: { eyebrow: "Dinheiro que já saiu", title: "Despesas", body: "Um registro leve do que aconteceu — e do que isso significa.", add: "Adicionar despesa", total: "Total gasto", transactions: "transações", search: "Buscar despesas", empty: "Nenhuma despesa corresponde a estes filtros.", selectOrganization: "Selecione uma organização", scoped: "As despesas pertencem a uma organização.", average: "Média", localFilters: "A busca e o status filtram as despesas exibidas nesta página.", expense: "Despesa", date: "Data", category: "Categoria", amount: "Valor", status: "Status", allStatuses: "Todos os status", paid: "Paga", pending: "Pendente", cancelled: "Cancelada", added: "Adicionada {{date}}", updated: "Despesa atualizada", created: "Despesa adicionada", removed: "Despesa removida", edit: "Editar despesa", formDescription: "Anote o que foi gasto e quando aconteceu.", notFound: "Despesa não encontrada.", detail: "Detalhes da despesa", occurred: "Aconteceu em", createdBy: "Criada por", attachmentDescription: "Baixe uma cópia de um documento anexado.", noAttachments: "Nenhum anexo ainda", receiptHint: "Adicione um recibo quando tiver um." }, + organization: { eyebrow: "Espaço compartilhado", title: "Organização", body: "Defina as regras e o contexto por trás dos números.", settings: "Configurações do espaço", budget: "Orçamento mensal", members: "Pessoas com acesso", memberBody: "Convide quem ajuda a tomar as decisões.", name: "Nome do espaço", membersTitle: "Membros", membersBody: "Uma visão simples de quem faz parte desta mesa financeira.", active: "Espaço ativo", created: "Criada em {{date}}", onlyEditable: "Renomeie o espaço exibido em toda a sua mesa.", settingsSaved: "Configurações do espaço salvas", boundary: "Um limite para o mês, não um julgamento.", monthlyLimit: "Limite mensal", notConfigured: "Não configurado", budgetSaved: "Orçamento salvo", monthlyBudget: "Orçamento mensal", dashboardUpdate: "O orçamento atualiza o painel", manageMembers: "Gerenciar membros", accessOverview: "Visão geral do acesso", everyoneAccess: "Todas as pessoas com acesso a este espaço.", protected: "Protegido", invitationCreated: "Convite criado", invitationCopied: "Link de convite copiado", invitationError: "Não foi possível criar o convite.", memberRemoved: "Membro removido", left: "Você saiu da organização", invitationReady: "Convite pronto", invitationDescription: "O convite é válido por 24 horas e não adiciona um membro até ser aceito.", invitationLink: "Link de convite", email: "Endereço de e-mail", role: "Função", roleFor: "Função de", roleUpdated: "Função do membro atualizada", roleError: "Não foi possível atualizar a função deste membro.", leave: "Sair da organização", remove: "Remover", removeError: "Não foi possível remover este membro.", roleUnavailable: "Função indisponível", joinedUnavailable: "Data de entrada indisponível", membersUnavailable: "Não foi possível carregar os membros.", notFound: "Organização não encontrada." }, + account: { eyebrow: "Suas preferências", title: "Conta", body: "Deixe a mesa com a sua cara.", profile: "Perfil", appearance: "Aparência", language: "Idioma", theme: "Tema", signOut: "Sair", reset: "Preferências da sessão", profileDescription: "O nome e o e-mail exibidos para seu espaço.", changeAvatar: "Alterar avatar", removeAvatar: "Remover avatar", languageDescription: "Escolha o idioma da interface", themeDescription: "Escolha uma mesa clara ou escura", billReminderEmails: "Lembretes de contas por e-mail", billReminderEmailsDescription: "Avise por e-mail antes, no dia e após o vencimento", billReminderEmailsUpgrade: "Disponível nos planos Basic e Premium", signOutAll: "Sair de todos os dispositivos", profileSaved: "Perfil salvo", unableSave: "Não foi possível salvar o perfil", invalidAvatar: "Use um avatar JPG, JPEG ou PNG de até 2 MiB.", avatarUpdated: "Avatar atualizado", unableUpload: "Não foi possível enviar o avatar", avatarRemoved: "Avatar removido" }, + notifications: { currentOrganization: "Da organização selecionada", markAllRead: "Marcar todas como lidas", unreadCount: "{{count}} notificações não lidas", empty: "Nenhuma novidade por aqui.", billDueSoon: { title: "Conta vence em breve", body: "{{billDescription}} vence em três dias." }, billDueToday: { title: "Conta vence hoje", body: "{{billDescription}} vence hoje." }, billOverdue: { title: "Conta atrasada", body: "{{billDescription}} está atrasada." }, memberJoined: { title: "Membro entrou", body: "{{memberName}} entrou na organização." }, memberRoleChanged: { title: "Função alterada", body: "{{memberName}} agora é {{newRole}}." }, memberRemoved: { title: "Membro removido", body: "{{memberName}} foi removido da organização." } }, + more: { eyebrow: "Mais / espaço", title: "Mais", body: "Os atalhos úteis da sua mesa financeira", budgetSettings: "Orçamento e configurações do espaço", access: "Pessoas com acesso", profilePreferences: "Perfil e preferências" }, + errors: { attention: "Algo precisa de atenção", tryAgain: "Tentar novamente", requestCanceled: "Solicitação cancelada.", validation: "Verifique os campos destacados." }, + api: { healthFailed: "A verificação de saúde falhou com {{status}}", account: { updateProfile: "Não foi possível atualizar seu perfil.", uploadAvatar: "Não foi possível enviar seu avatar.", removeAvatar: "Não foi possível remover seu avatar." }, auth: { createAccount: "Não foi possível criar sua conta.", signIn: "Não foi possível entrar.", restoreSession: "Não foi possível restaurar sua sessão.", signOut: "Não foi possível sair.", signOutAll: "Não foi possível sair de todas as sessões.", loadAccount: "Não foi possível carregar sua conta." }, bills: { load: "Não foi possível carregar as contas.", loadOne: "Não foi possível carregar esta conta.", create: "Não foi possível criar a conta.", update: "Não foi possível atualizar a conta.", delete: "Não foi possível excluir a conta.", uploadDocument: "Não foi possível enviar o documento da conta.", openDocument: "Não foi possível abrir o documento da conta.", prepareDownload: "Não foi possível preparar o download.", removeDocument: "Não foi possível remover o documento da conta.", stopFuture: "Não foi possível interromper as contas futuras." }, dashboard: { summary: "Não foi possível carregar o resumo do painel.", upcoming: "Não foi possível carregar as próximas contas.", recent: "Não foi possível carregar as despesas recentes." }, expenses: { load: "Não foi possível carregar as despesas.", loadOne: "Não foi possível carregar esta despesa.", create: "Não foi possível criar a despesa.", update: "Não foi possível atualizar a despesa.", delete: "Não foi possível excluir a despesa.", uploadDocument: "Não foi possível enviar o documento da despesa.", openDocument: "Não foi possível abrir o documento da despesa.", removeDocument: "Não foi possível remover o documento da despesa." }, organizations: { load: "Não foi possível carregar as organizações.", loadOne: "Não foi possível carregar esta organização.", create: "Não foi possível criar a organização.", update: "Não foi possível atualizar a organização.", loadBudget: "Não foi possível carregar o orçamento.", saveBudget: "Não foi possível salvar o orçamento.", createInvitation: "Não foi possível criar o convite.", updateRole: "Não foi possível atualizar a função deste membro.", removeMember: "Não foi possível remover este membro.", join: "Não foi possível entrar na organização." }, notifications: { load: "Não foi possível carregar as notificações.", markRead: "Não foi possível atualizar as notificações.", loadPreferences: "Não foi possível carregar as preferências de notificação.", savePreferences: "Não foi possível salvar as preferências de notificação." } }, + types: { housing: "Moradia", utilities: "Serviços", food: "Alimentação", transportation: "Transporte", healthcare: "Saúde", subscriptions: "Assinaturas", education: "Educação", insurance: "Seguro", personal: "Pessoal", taxes: "Impostos", miscellaneous: "Diversos", travel: "Viagens", gifts: "Presentes", pets: "Animais de estimação", recurring: "Recorrente", installment: "Parcelada", oneTime: "Avulsa" }, + statuses: { upcoming: "Próxima", due: "Vence em breve", overdue: "Atrasada", paid: "Paga", pending: "Pendente", cancelled: "Cancelada", unknown: "Desconhecido" }, + roles: { Owner: "Proprietário", Admin: "Administrador", Member: "Membro" }, + documents: { Invoice: "Fatura", Receipt: "Recibo", Boleto: "Boleto", Contract: "Contrato", Other: "Outro" }, } }, -}; +} as const; + +function updateDocumentLanguage(language: string) { + const locale = language === "pt-BR" ? "pt-BR" : "en-US"; + document.documentElement.lang = locale; + document.title = i18next.t("meta.title", { lng: locale }); + const description = document.querySelector('meta[name="description"]'); + if (description) description.content = i18next.t("meta.description", { lng: locale }); +} void i18next.use(initReactI18next).init({ resources, @@ -32,5 +66,7 @@ void i18next.use(initReactI18next).init({ fallbackLng: "en-US", interpolation: { escapeValue: false }, }); +i18next.on("languageChanged", updateDocumentLanguage); +updateDocumentLanguage(i18next.language); export default i18next; diff --git a/apps/frontend-v2/src/lib/query-keys.ts b/apps/frontend-v2/src/lib/query-keys.ts index d288d45..d8da524 100644 --- a/apps/frontend-v2/src/lib/query-keys.ts +++ b/apps/frontend-v2/src/lib/query-keys.ts @@ -25,4 +25,10 @@ export const queryKeys = { list: (organizationId: string, page: number, pageSize: number, from?: Date, to?: Date) => ["expenses", "list", organizationId, page, pageSize, dateKey(from), dateKey(to)] as const, detail: (organizationId: string, expenseId: string) => ["expenses", "detail", organizationId, expenseId] as const, }, + notifications: { + all: ["notifications"] as const, + list: (organizationId: string) => ["notifications", organizationId, "list"] as const, + unread: (organizationId: string) => ["notifications", organizationId, "unread"] as const, + preferences: (organizationId: string) => ["notifications", organizationId, "preferences"] as const, + }, } as const; diff --git a/apps/frontend-v2/src/notification-bell.tsx b/apps/frontend-v2/src/notification-bell.tsx new file mode 100644 index 0000000..9d2cd63 --- /dev/null +++ b/apps/frontend-v2/src/notification-bell.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from "react"; +import { Bell, CheckCheck, ReceiptText, UsersRound } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import type { AppNotification, NotificationType } from "./api/notifications/notifications.types"; +import { useOrganizationStore } from "./auth/auth-store"; +import { useNotificationMutations, useNotificationsQuery, useNotificationUnreadCountQuery } from "./hooks/use-queries"; + +const billTypes = new Set(["BillDueSoon", "BillDueToday", "BillOverdue"]); + +function NotificationCopy({ notification }: { notification: AppNotification }) { + const { t } = useTranslation(); + const key = notification.type.charAt(0).toLowerCase() + notification.type.slice(1); + return {t(`notifications.${key}.title`)}{t(`notifications.${key}.body`, { ...notification.parameters })}; +} + +export function NotificationBell() { + const { t } = useTranslation(); + const organizationId = useOrganizationStore((state) => state.selectedOrganizationId); + const [open, setOpen] = useState(false); + const root = useRef(null); + const notifications = useNotificationsQuery(organizationId, open); + const unread = useNotificationUnreadCountQuery(organizationId); + const mutations = useNotificationMutations(organizationId); + + useEffect(() => { + if (!open) return; + const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; + const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; + document.addEventListener("pointerdown", close); + document.addEventListener("keydown", escape); + return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", escape); }; + }, [open]); + + const items = notifications.data?.data ?? []; + return
+ + {open &&
+
{t("common.notifications")}{t("notifications.currentOrganization")}{(unread.data ?? 0) > 0 && }
+
+ {notifications.isPending &&

{t("common.loading")}

} + {notifications.isError &&

{t("api.notifications.load")}

} + {!notifications.isPending && !notifications.isError && items.length === 0 &&

{t("notifications.empty")}

} + {items.map((notification) => { + const Icon = billTypes.has(notification.type) ? ReceiptText : UsersRound; + return { setOpen(false); if (!notification.readAt) mutations.markRead.mutate(notification.id); }}>; + })} +
+
} +
; +} diff --git a/apps/frontend-v2/src/styles.css b/apps/frontend-v2/src/styles.css index d632ccc..16bd149 100644 --- a/apps/frontend-v2/src/styles.css +++ b/apps/frontend-v2/src/styles.css @@ -9,6 +9,7 @@ text-rendering: optimizeLegibility; --ink: #132238; --ink-soft: #536273; + --ink-deep: #132238; --muted: #8390a1; --paper: #f6f8fa; --surface: #ffffff; @@ -51,7 +52,7 @@ a { color: inherit; text-decoration: none; } .signal-dot, .live-dot { display: inline-block; width: 7px; height: 7px; flex: 0 0 auto; border-radius: 999px; background: var(--mint); box-shadow: 0 0 0 4px rgba(35, 184, 154, .14); } .sidebar__signal .signal-dot { width: 6px; height: 6px; margin-left: auto; box-shadow: none; } .main-content { display: flex; min-width: 0; flex: 1; flex-direction: column; margin-left: 248px; } -.content-topbar { display: flex; height: 76px; align-items: center; justify-content: space-between; padding: 0 38px; border-bottom: 1px solid var(--line); background: rgba(249, 251, 252, .78); backdrop-filter: blur(16px); } +.content-topbar { position: relative; z-index: 50; display: flex; height: 76px; align-items: center; justify-content: space-between; padding: 0 38px; border-bottom: 1px solid var(--line); background: rgba(249, 251, 252, .78); backdrop-filter: blur(16px); } .content-topbar__crumb { display: flex; align-items: center; gap: 9px; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 11px; letter-spacing: .02em; } .content-topbar__crumb span:not(.live-dot) { color: #bbc3cd; } .content-topbar__actions { display: flex; align-items: center; gap: 10px; } @@ -77,6 +78,7 @@ a { color: inherit; text-decoration: none; } .button:disabled { cursor: not-allowed; opacity: .48; transform: none; box-shadow: none; } .icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--ink-soft); } .icon-button:hover { border-color: var(--line); background: var(--surface); color: var(--ink); } +.notification-bell { position: relative; }.notification-bell__trigger { position: relative; }.notification-bell__badge { position: absolute; top: -5px; right: -6px; display: grid; min-width: 17px; height: 17px; place-items: center; border: 2px solid var(--paper); border-radius: 999px; padding: 0 3px; background: var(--coral); color: white; font-family: "IBM Plex Mono", monospace; font-size: 8px; font-weight: 700; }.notification-panel { position: absolute; z-index: 80; top: calc(100% + 10px); right: 0; width: min(390px, calc(100vw - 36px)); overflow: hidden; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: 0 20px 55px rgba(13, 24, 40, .18); animation: modal-in .16s ease both; }.notification-panel__header { display: flex; min-height: 67px; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding: 13px 16px; }.notification-panel__header > span { display: grid; gap: 3px; }.notification-panel__header strong { font-family: "Space Grotesk", sans-serif; font-size: 15px; }.notification-panel__header small { color: var(--muted); font-size: 9px; }.notification-panel__header button { display: flex; align-items: center; gap: 5px; border: 0; padding: 5px; background: transparent; color: var(--blue); font-size: 9px; font-weight: 700; }.notification-panel__header button:disabled { opacity: .5; }.notification-panel__list { max-height: min(470px, calc(100vh - 170px)); overflow-y: auto; }.notification-panel__state { margin: 0; padding: 34px 18px; color: var(--muted); text-align: center; font-size: 11px; }.notification-item { position: relative; display: grid; grid-template-columns: auto 1fr; gap: 11px; border-bottom: 1px solid var(--line); padding: 14px 16px; color: var(--ink); }.notification-item:last-child { border-bottom: 0; }.notification-item:hover { background: var(--paper); }.notification-item--unread { background: var(--blue-soft); }.notification-item--unread::before { position: absolute; top: 18px; left: 5px; width: 4px; height: 4px; border-radius: 50%; background: var(--blue); content: ""; }.notification-item__icon { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 9px; background: var(--surface); color: var(--blue); box-shadow: inset 0 0 0 1px var(--line); }.notification-item__copy { display: grid; min-width: 0; gap: 3px; }.notification-item__copy strong { font-size: 11px; }.notification-item__copy small { overflow: hidden; color: var(--ink-soft); font-size: 10px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }.notification-item__copy time { color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 8px; } .text-link { color: var(--blue); font-size: 13px; font-weight: 600; } .text-link:hover, .inline-link:hover { text-decoration: underline; } .back-link { display: inline-flex; margin-bottom: 18px; color: var(--muted); font-size: 13px; } @@ -124,7 +126,7 @@ a { color: inherit; text-decoration: none; } .metric-card--mint .metric-card__top svg { color: var(--mint); } .metric-card--amber { background: linear-gradient(135deg, #fff 36%, #fff9ec); } .metric-card--amber .metric-card__top svg { color: var(--amber); } -.metric-card--ink { background: var(--ink); color: white; } +.metric-card--ink { background: var(--ink-deep); color: white; } .metric-card--ink .metric-card__top, .metric-card--ink p { color: #aab9cd; } .metric-card--ink .metric-card__top svg { color: #8faaff; } .meter { height: 4px; margin-top: 12px; overflow: hidden; border-radius: 99px; background: #e9eff8; } @@ -156,7 +158,7 @@ a { color: inherit; text-decoration: none; } .period-popover__reset { margin-right: auto; border: 0; background: transparent; color: var(--blue); font-size: 11px; font-weight: 600; } .period-popover__reset:hover { text-decoration: underline; } @media (max-width: 820px) { .period-picker, .period-control { width: 100%; }.period-popover { right: auto; left: 0; width: min(310px, calc(100vw - 36px)); } } -.timeline-card { position: relative; margin-bottom: 14px; border: 1px solid var(--line); border-radius: var(--radius); padding: 23px 24px 18px; background: var(--ink); color: white; overflow: hidden; } +.timeline-card { position: relative; margin-bottom: 14px; border: 1px solid var(--line); border-radius: var(--radius); padding: 23px 24px 18px; background: var(--ink-deep); color: white; overflow: hidden; } .timeline-card::after { position: absolute; top: -100px; right: -80px; width: 280px; height: 280px; border: 1px solid rgba(143, 170, 255, .18); border-radius: 50%; content: ""; box-shadow: 0 0 0 30px rgba(143, 170, 255, .04), 0 0 0 60px rgba(143, 170, 255, .025); } .timeline-card__header { position: relative; z-index: 1; display: flex; justify-content: space-between; gap: 20px; } .timeline-card .eyebrow { color: #8faaff; } @@ -167,7 +169,7 @@ a { color: inherit; text-decoration: none; } .timeline::before { position: absolute; top: 38px; right: 0; left: 0; height: 1px; background: #3a4c68; content: ""; } .timeline-event { position: relative; display: grid; min-width: 150px; gap: 8px; padding-right: 20px; color: #aebbd0; animation: timeline-in .45s both; animation-delay: calc(var(--event-index) * 55ms); } .timeline-event__date { color: #8faaff; font-family: "IBM Plex Mono", monospace; font-size: 9px; line-height: 11px; } -.timeline-event__dot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--ink); border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(233, 162, 59, .15); } +.timeline-event__dot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--ink-deep); border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(233, 162, 59, .15); } .timeline-event--expense .timeline-event__dot { background: var(--mint); box-shadow: 0 0 0 4px rgba(35, 184, 154, .15); } .timeline-event__label { max-width: 140px; overflow: hidden; color: #e5ebf4; font-size: 11px; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } .timeline-event strong { color: white; font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 500; } @@ -217,27 +219,38 @@ a { color: inherit; text-decoration: none; } .search-field, .select-field { display: flex; min-height: 40px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 9px; padding: 0 11px; background: var(--surface); color: var(--muted); }.search-field { flex: 1; max-width: 380px; }.search-field input, .select-field select { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 12px; }.search-field input { width: 100%; }.select-field select { padding-right: 5px; cursor: pointer; } .surface-card--table { overflow: visible; }.table-head, .table-row { display: grid; grid-template-columns: 2.2fr 1fr 1fr .9fr .9fr 42px; align-items: center; gap: 13px; padding: 0 20px; }.table-head { min-height: 42px; border-bottom: 1px solid var(--line); color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 9px; letter-spacing: .07em; text-transform: uppercase; }.table-row { position: relative; min-height: 76px; border-bottom: 1px solid #edf0f4; color: var(--ink-soft); font-size: 12px; }.table-row:last-child { border-bottom: 0; }.table-row > span, .table-row > strong { min-width: 0; }.table-row > span small, .table-row__primary small { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }.table-row__primary { display: flex; align-items: center; gap: 10px; min-width: 0; }.table-row__primary > span { display: grid; min-width: 0; }.table-row__primary strong { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }.table-row > strong { color: var(--ink); font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 600; }.type-label { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; text-transform: capitalize; }.muted { color: var(--muted); font-size: 11px; }.row-actions { position: relative; justify-self: end; }.action-menu { position: absolute; z-index: 10; top: 38px; right: 0; display: grid; min-width: 160px; padding: 5px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }.action-menu button, .action-menu a { display: flex; align-items: center; gap: 8px; border: 0; border-radius: 6px; padding: 8px 9px; background: transparent; color: var(--ink-soft); font-size: 11px; text-align: left; }.action-menu button:hover, .action-menu a:hover { background: var(--paper); color: var(--blue); }.action-menu__danger { color: var(--coral) !important; }.base-menu { z-index: 110; min-width: 164px; padding: 5px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); outline: 0; }.base-menu__item { display: flex; align-items: center; gap: 8px; border-radius: 6px; padding: 8px 9px; color: var(--ink-soft); font-size: 11px; cursor: pointer; outline: 0; }.base-menu__item[data-highlighted] { background: var(--paper); color: var(--blue); }.base-menu__item--danger { color: var(--coral); } .empty-state { display: grid; justify-items: center; gap: 8px; padding: 58px 20px; text-align: center; }.empty-state__icon { display: inline-grid; width: 44px; height: 44px; place-items: center; border-radius: 13px; background: var(--paper); color: var(--muted); }.empty-state h3 { margin: 4px 0 0; font-family: "Space Grotesk", sans-serif; font-size: 16px; }.empty-state p { max-width: 290px; margin: 0 0 8px; color: var(--muted); font-size: 12px; line-height: 1.5; } +.spinner { width: 26px; height: 26px; border: 3px solid var(--line-strong); border-top-color: var(--blue); border-radius: 50%; animation: spin .75s linear infinite; } .detail-grid { display: grid; grid-template-columns: .9fr 1.1fr; gap: 14px; }.detail-card { min-height: 280px; padding: 23px; }.detail-card__amount { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding-bottom: 22px; border-bottom: 1px solid var(--line); }.detail-card__amount span:first-child { color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 10px; text-transform: uppercase; }.detail-card__amount strong { margin-top: 22px; margin-right: auto; font-family: "Space Grotesk", sans-serif; font-size: 33px; letter-spacing: -.06em; }.detail-list { display: grid; gap: 15px; margin: 22px 0 0; }.detail-list div { display: flex; justify-content: space-between; gap: 20px; }.detail-list dt { color: var(--muted); font-size: 11px; }.detail-list dd { margin: 0; color: var(--ink); font-size: 12px; font-weight: 600; text-align: right; }.attachment-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--line); }.attachment-row > span:nth-child(2) { display: grid; flex: 1; gap: 4px; min-width: 0; }.attachment-row strong { overflow-wrap: anywhere; font-size: 12px; }.attachment-row small { color: var(--muted); font-size: 10px; } .modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(13, 24, 40, .5); backdrop-filter: blur(5px); }.modal { width: min(100%, 500px); max-height: calc(100vh - 40px); overflow-y: auto; border: 1px solid var(--line); border-radius: 18px; padding: 24px; background: var(--surface); box-shadow: 0 24px 80px rgba(4, 14, 27, .25); animation: modal-in .2s ease both; }.modal--wide { width: min(100%, 680px); }.modal__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 24px; }.modal__header h2 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 23px; letter-spacing: -.05em; }.modal__header p { margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }.modal-form { display: grid; gap: 15px; }.modal-form label, .field-label, .auth-form label, .account-form label { display: grid; gap: 7px; }.modal-form label > span, .field-label > span, .auth-form label > span, .account-form label > span { color: var(--ink-soft); font-size: 11px; font-weight: 600; }.modal-form input, .modal-form select, .field-label input, .auth-form input, .account-form input, .inline-form input, .preference-row select { min-height: 42px; width: 100%; border: 1px solid var(--line-strong); border-radius: 9px; padding: 0 12px; outline: 0; background: var(--surface); color: var(--ink); font-size: 13px; }.modal-form input:focus, .modal-form select:focus, .field-label input:focus, .auth-form input:focus, .account-form input:focus, .inline-form input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(47, 91, 234, .1); }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }.modal-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } .organization-layout, .account-layout { display: grid; gap: 14px; }.organization-hero { display: flex; align-items: center; gap: 14px; padding: 22px; }.organization-hero__mark { display: inline-grid; width: 52px; height: 52px; place-items: center; border-radius: 15px; background: var(--blue-soft); color: var(--blue); }.organization-hero h2 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 22px; letter-spacing: -.045em; }.organization-hero p:last-child { margin: 5px 0 0; color: var(--muted); font-size: 11px; }.organization-hero__status { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; color: var(--mint); font-family: "IBM Plex Mono", monospace; font-size: 10px; }.organization-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.organization-grid > section, .organization-members-preview, .profile-card, .preferences-card { padding: 22px; }.budget-card { background: linear-gradient(135deg, #fff, #f5f8ff); }.budget-card__number { display: grid; gap: 6px; margin: 28px 0 17px; }.budget-card__number span { color: var(--muted); font-size: 11px; }.budget-card__number strong { font-family: "Space Grotesk", sans-serif; font-size: 36px; letter-spacing: -.06em; }.inline-form { display: flex; gap: 8px; }.inline-form input { flex: 1; }.budget-card__footer { display: flex; align-items: center; gap: 5px; margin-top: 18px; color: var(--muted); font-size: 10px; }.member-stack { display: flex; flex-wrap: wrap; gap: 9px; }.member-chip { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 11px; padding: 8px 11px 8px 8px; }.member-chip > span:last-child { display: grid; gap: 2px; }.member-chip strong { font-size: 11px; }.member-chip small { color: var(--muted); font-size: 10px; }.members-card { padding: 22px; }.members-card__summary { display: flex; align-items: flex-start; justify-content: space-between; padding-bottom: 22px; border-bottom: 1px solid var(--line); }.members-card__summary strong { display: block; margin-top: 8px; font-family: "Space Grotesk", sans-serif; font-size: 30px; letter-spacing: -.06em; }.members-card__summary p { margin: 5px 0 0; color: var(--muted); font-size: 11px; }.members-card__badge { display: inline-flex; align-items: center; gap: 5px; color: var(--mint); font-size: 11px; font-weight: 600; }.members-list { display: grid; }.member-row { display: flex; min-height: 72px; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); }.member-row:last-child { border-bottom: 0; }.member-row > span:nth-child(2) { display: grid; flex: 1; gap: 4px; }.member-row strong { font-size: 12px; }.member-row small, .member-row__joined { color: var(--muted); font-size: 10px; }.member-row__joined { margin-right: 8px; }.role-badge { border-radius: 999px; padding: 5px 8px; font-family: "IBM Plex Mono", monospace; font-size: 9px; }.role-badge--owner { background: var(--blue-soft); color: var(--blue); }.role-badge--admin { background: var(--amber-soft); color: #a56810; }.role-badge--member { background: var(--mint-soft); color: #0a8a70; } .profile-card__identity { display: flex; align-items: center; gap: 12px; margin: 23px 0; }.profile-card__identity > div { display: grid; flex: 1; gap: 4px; }.profile-card__identity strong { font-family: "Space Grotesk", sans-serif; font-size: 16px; }.profile-card__identity span { color: var(--muted); font-size: 11px; }.account-form { align-items: end; }.preferences-card { display: grid; align-content: start; }.preference-row { display: flex; min-height: 67px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); }.preference-row > span:first-child { display: flex; align-items: center; gap: 10px; color: var(--blue); }.preference-row > span:first-child > span { display: grid; gap: 4px; }.preference-row strong { color: var(--ink); font-size: 12px; }.preference-row small { color: var(--muted); font-size: 10px; }.preference-row select { width: auto; min-height: 34px; padding: 0 9px; font-size: 11px; }.theme-pills { display: flex; gap: 4px; }.theme-pill { border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; background: var(--surface); color: var(--muted); font-size: 10px; }.theme-pill--active { border-color: var(--blue); background: var(--blue-soft); color: var(--blue); }.preference-row--danger > span:first-child { color: var(--coral); }.account-signout { margin-top: 12px; padding-top: 16px; border-top: 1px solid var(--line); } +.switch { position: relative; display: inline-flex; flex: 0 0 auto; }.switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }.switch span { width: 39px; height: 22px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--paper); transition: .18s ease; }.switch span::after { display: block; width: 16px; height: 16px; margin: 2px; border-radius: 50%; background: var(--muted); content: ""; transition: .18s ease; }.switch input:checked + span { border-color: var(--blue); background: var(--blue); }.switch input:checked + span::after { transform: translateX(17px); background: white; }.switch input:focus-visible + span { outline: 3px solid rgba(47, 91, 234, .24); outline-offset: 2px; }.switch input:disabled + span { cursor: not-allowed; opacity: .45; } .more-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } -.public-shell { min-height: 100vh; background: var(--paper); }.public-nav { display: flex; height: 82px; align-items: center; justify-content: space-between; width: min(100% - 72px, 1250px); margin: 0 auto; }.public-nav__actions { display: flex; align-items: center; gap: 19px; }.language-switch { display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 10px; font-weight: 600; }.landing { width: min(100% - 72px, 1250px); margin: 0 auto; }.landing-hero { display: grid; min-height: 620px; grid-template-columns: .85fr 1.15fr; align-items: center; gap: 60px; padding: 60px 0 80px; }.landing-hero__copy h1 { max-width: 600px; margin: 0; font-family: "Space Grotesk", sans-serif; font-size: clamp(48px, 6vw, 78px); font-weight: 600; letter-spacing: -.08em; line-height: .98; }.landing-hero__body { max-width: 480px; margin: 25px 0 28px; color: var(--ink-soft); font-size: 16px; line-height: 1.65; }.landing-hero__actions { display: flex; flex-wrap: wrap; gap: 9px; }.landing-hero__trust { display: flex; align-items: center; gap: 12px; margin-top: 36px; color: var(--muted); font-size: 11px; }.landing-hero__trust strong { color: var(--ink); }.avatar-stack { display: flex; }.avatar-stack .avatar { margin-right: -8px; border: 2px solid var(--paper); }.landing-hero__visual { position: relative; min-height: 470px; display: grid; place-items: center; }.hero-orbit { position: absolute; border: 1px solid rgba(47, 91, 234, .12); border-radius: 50%; transform: rotate(-19deg); }.hero-orbit--one { width: 390px; height: 490px; }.hero-orbit--two { width: 480px; height: 290px; border-color: rgba(35, 184, 154, .16); transform: rotate(27deg); }.hero-desk-card { position: relative; z-index: 1; width: min(100%, 430px); border: 1px solid #30445f; border-radius: 18px; padding: 21px; background: var(--ink); color: white; box-shadow: 0 28px 60px rgba(19, 34, 56, .2); transform: rotate(2deg); }.hero-desk-card__header { display: flex; align-items: center; gap: 7px; color: #a7b8cf; font-family: "IBM Plex Mono", monospace; font-size: 9px; }.hero-desk-card__header > svg { margin-left: auto; }.hero-desk-card__balance { display: grid; gap: 7px; margin: 42px 0 34px; }.hero-desk-card__balance > span { color: #95a7bd; font-size: 11px; }.hero-desk-card__balance strong { font-family: "Space Grotesk", sans-serif; font-size: 41px; letter-spacing: -.07em; }.hero-desk-card__balance small { display: flex; align-items: center; gap: 5px; color: #7de1cd; font-size: 10px; }.hero-mini-timeline { position: relative; padding-top: 5px; }.hero-mini-timeline__line { display: block; height: 1px; background: #3c506b; }.hero-mini-timeline__dot { position: absolute; top: 0; width: 10px; height: 10px; border: 2px solid var(--ink); border-radius: 50%; }.hero-mini-timeline__dot--past { background: #7f91a9; }.hero-mini-timeline__dot--mint { background: var(--mint); }.hero-mini-timeline__dot--amber { background: var(--amber); }.hero-mini-timeline__dot--coral { background: var(--coral); }.hero-mini-timeline__labels { display: flex; justify-content: space-between; margin-top: 10px; color: #8294ad; font-family: "IBM Plex Mono", monospace; font-size: 9px; }.hero-desk-card__rows { display: grid; gap: 12px; margin-top: 29px; padding-top: 17px; border-top: 1px solid #30445f; }.hero-desk-card__rows span { display: flex; align-items: center; gap: 7px; color: #afbdd0; font-size: 10px; }.hero-desk-card__rows b { margin-left: auto; color: white; font-family: "IBM Plex Mono", monospace; font-size: 10px; font-weight: 500; }.hero-float { position: absolute; z-index: 2; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line); border-radius: 13px; padding: 12px; background: rgba(255, 255, 255, .92); box-shadow: var(--shadow); }.hero-float svg { color: var(--mint); }.hero-float span:not(.hero-float__check) { display: grid; gap: 3px; }.hero-float strong { font-family: "IBM Plex Mono", monospace; font-size: 11px; }.hero-float small { color: var(--muted); font-size: 9px; }.hero-float--top { top: 74px; right: 6%; transform: rotate(4deg); }.hero-float--bottom { bottom: 63px; left: 2%; transform: rotate(-4deg); }.hero-float__check { display: inline-grid; width: 24px; height: 24px; place-items: center; border-radius: 8px; background: var(--mint-soft); color: var(--mint); }.landing-signal { padding: 70px 0 115px; border-top: 1px solid var(--line); }.landing-signal > div:first-child { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 38px; }.landing-signal h2 { max-width: 330px; margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 39px; letter-spacing: -.07em; line-height: 1; }.landing-signal__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 17px; }.landing-signal__grid article { position: relative; min-height: 205px; border-top: 1px solid var(--ink); padding: 19px 4px; }.landing-signal__grid article svg { color: var(--blue); }.feature-number { position: absolute; top: 18px; right: 3px; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 10px; }.landing-signal__grid h3 { margin: 32px 0 8px; font-family: "Space Grotesk", sans-serif; font-size: 19px; letter-spacing: -.04em; }.landing-signal__grid p { max-width: 270px; margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.55; }.public-footer { display: flex; width: min(100% - 72px, 1250px); align-items: center; justify-content: space-between; margin: 0 auto; padding: 22px 0 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; }.public-footer .brand-mark { font-size: 15px; } -.auth-layout { display: grid; min-height: calc(100vh - 82px); grid-template-columns: .9fr 1.1fr; }.auth-aside { position: relative; display: flex; min-height: 630px; flex-direction: column; justify-content: center; padding: 65px max(8vw, 60px); background: var(--ink); color: white; overflow: hidden; }.auth-aside::after { position: absolute; right: -140px; bottom: -180px; width: 430px; height: 430px; border: 1px solid rgba(143, 170, 255, .22); border-radius: 50%; content: ""; box-shadow: 0 0 0 35px rgba(143, 170, 255, .04), 0 0 0 70px rgba(143, 170, 255, .035); }.auth-aside__inner { position: relative; z-index: 1; max-width: 450px; }.auth-aside .eyebrow { color: #8faaff; }.auth-aside h1 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: clamp(40px, 5vw, 67px); letter-spacing: -.08em; line-height: .98; }.auth-aside__inner > p:not(.eyebrow) { max-width: 360px; margin: 24px 0 0; color: #adbbce; font-size: 15px; line-height: 1.6; }.auth-aside__note { display: flex; align-items: flex-start; gap: 10px; margin-top: 40px; border-top: 1px solid #31445e; padding-top: 17px; color: #8faaff; font-size: 11px; line-height: 1.4; }.auth-aside__stamp { position: absolute; bottom: 31px; left: max(8vw, 60px); color: #566a85; font-family: "IBM Plex Mono", monospace; font-size: 10px; letter-spacing: .13em; }.auth-panel { display: flex; flex-direction: column; padding: 35px max(8vw, 70px); background: var(--surface); }.auth-panel__top { display: flex; align-items: center; justify-content: space-between; }.auth-panel__top .language-switch { margin-left: auto; }.auth-form { width: min(100%, 410px); margin: auto; }.auth-form__heading { display: flex; align-items: center; gap: 12px; margin-bottom: 34px; }.auth-form__icon { display: inline-grid; width: 43px; height: 43px; place-items: center; border-radius: 12px; background: var(--blue-soft); color: var(--blue); }.auth-form__heading h2 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 27px; letter-spacing: -.06em; }.auth-form__heading .eyebrow { margin-bottom: 5px; }.auth-form > label { margin-bottom: 15px; }.input-with-icon { position: relative; }.input-with-icon svg { position: absolute; top: 13px; left: 12px; color: var(--muted); }.input-with-icon input { padding-left: 38px; }.form-error { margin: -2px 0 12px; color: var(--coral); font-size: 11px; }.auth-form__switch { margin: 19px 0 0; color: var(--muted); font-size: 11px; text-align: center; }.auth-form__switch a { color: var(--blue); font-weight: 600; }.auth-panel__footer { margin-top: auto; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 9px; }.auth-panel__footer span { display: inline-flex; align-items: center; gap: 6px; } +.public-shell { min-height: 100vh; background: var(--paper); }.public-nav { display: flex; height: 82px; align-items: center; justify-content: space-between; width: min(100% - 72px, 1250px); margin: 0 auto; }.public-nav__actions { display: flex; align-items: center; gap: 19px; }.language-switch { display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 10px; font-weight: 600; }.landing { width: min(100% - 72px, 1250px); margin: 0 auto; }.landing-hero { display: grid; min-height: 620px; grid-template-columns: .85fr 1.15fr; align-items: center; gap: 60px; padding: 60px 0 80px; }.landing-hero__copy h1 { max-width: 600px; margin: 0; font-family: "Space Grotesk", sans-serif; font-size: clamp(48px, 6vw, 78px); font-weight: 600; letter-spacing: -.08em; line-height: .98; }.landing-hero__body { max-width: 480px; margin: 25px 0 28px; color: var(--ink-soft); font-size: 16px; line-height: 1.65; }.landing-hero__actions { display: flex; flex-wrap: wrap; gap: 9px; }.landing-hero__trust { display: flex; align-items: center; gap: 12px; margin-top: 36px; color: var(--muted); font-size: 11px; }.landing-hero__trust strong { color: var(--ink); }.avatar-stack { display: flex; }.avatar-stack .avatar { margin-right: -8px; border: 2px solid var(--paper); }.landing-hero__visual { position: relative; min-height: 470px; display: grid; place-items: center; }.hero-orbit { position: absolute; border: 1px solid rgba(47, 91, 234, .12); border-radius: 50%; transform: rotate(-19deg); }.hero-orbit--one { width: 390px; height: 490px; }.hero-orbit--two { width: 480px; height: 290px; border-color: rgba(35, 184, 154, .16); transform: rotate(27deg); }.hero-desk-card { position: relative; z-index: 1; width: min(100%, 430px); border: 1px solid #30445f; border-radius: 18px; padding: 21px; background: var(--ink-deep); color: white; box-shadow: 0 28px 60px rgba(19, 34, 56, .2); transform: rotate(2deg); }.hero-desk-card__header { display: flex; align-items: center; gap: 7px; color: #a7b8cf; font-family: "IBM Plex Mono", monospace; font-size: 9px; }.hero-desk-card__header > svg { margin-left: auto; }.hero-desk-card__balance { display: grid; gap: 7px; margin: 42px 0 34px; }.hero-desk-card__balance > span { color: #95a7bd; font-size: 11px; }.hero-desk-card__balance strong { font-family: "Space Grotesk", sans-serif; font-size: 41px; letter-spacing: -.07em; }.hero-desk-card__balance small { display: flex; align-items: center; gap: 5px; color: #7de1cd; font-size: 10px; }.hero-mini-timeline { position: relative; padding-top: 5px; }.hero-mini-timeline__line { display: block; height: 1px; background: #3c506b; }.hero-mini-timeline__dot { position: absolute; top: 0; width: 10px; height: 10px; border: 2px solid var(--ink); border-radius: 50%; }.hero-mini-timeline__dot--past { background: #7f91a9; }.hero-mini-timeline__dot--mint { background: var(--mint); }.hero-mini-timeline__dot--amber { background: var(--amber); }.hero-mini-timeline__dot--coral { background: var(--coral); }.hero-mini-timeline__labels { display: flex; justify-content: space-between; margin-top: 10px; color: #8294ad; font-family: "IBM Plex Mono", monospace; font-size: 9px; }.hero-desk-card__rows { display: grid; gap: 12px; margin-top: 29px; padding-top: 17px; border-top: 1px solid #30445f; }.hero-desk-card__rows span { display: flex; align-items: center; gap: 7px; color: #afbdd0; font-size: 10px; }.hero-desk-card__rows b { margin-left: auto; color: white; font-family: "IBM Plex Mono", monospace; font-size: 10px; font-weight: 500; }.hero-float { position: absolute; z-index: 2; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line); border-radius: 13px; padding: 12px; background: rgba(255, 255, 255, .92); box-shadow: var(--shadow); }.hero-float svg { color: var(--mint); }.hero-float span:not(.hero-float__check) { display: grid; gap: 3px; }.hero-float strong { font-family: "IBM Plex Mono", monospace; font-size: 11px; }.hero-float small { color: var(--muted); font-size: 9px; }.hero-float--top { top: 74px; right: 6%; transform: rotate(4deg); }.hero-float--bottom { bottom: 63px; left: 2%; transform: rotate(-4deg); }.hero-float__check { display: inline-grid; width: 24px; height: 24px; place-items: center; border-radius: 8px; background: var(--mint-soft); color: var(--mint); }.landing-signal { padding: 70px 0 115px; border-top: 1px solid var(--line); }.landing-signal > div:first-child { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 38px; }.landing-signal h2 { max-width: 330px; margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 39px; letter-spacing: -.07em; line-height: 1; }.landing-signal__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 17px; }.landing-signal__grid article { position: relative; min-height: 205px; border-top: 1px solid var(--ink); padding: 19px 4px; }.landing-signal__grid article svg { color: var(--blue); }.feature-number { position: absolute; top: 18px; right: 3px; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 10px; }.landing-signal__grid h3 { margin: 32px 0 8px; font-family: "Space Grotesk", sans-serif; font-size: 19px; letter-spacing: -.04em; }.landing-signal__grid p { max-width: 270px; margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.55; }.public-footer { display: flex; width: min(100% - 72px, 1250px); align-items: center; justify-content: space-between; margin: 0 auto; padding: 22px 0 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; }.public-footer .brand-mark { font-size: 15px; } +.auth-layout { display: grid; min-height: calc(100vh - 82px); grid-template-columns: .9fr 1.1fr; }.auth-aside { position: relative; display: flex; min-height: 630px; flex-direction: column; justify-content: center; padding: 65px max(8vw, 60px); background: var(--ink-deep); color: white; overflow: hidden; }.auth-aside::after { position: absolute; right: -140px; bottom: -180px; width: 430px; height: 430px; border: 1px solid rgba(143, 170, 255, .22); border-radius: 50%; content: ""; box-shadow: 0 0 0 35px rgba(143, 170, 255, .04), 0 0 0 70px rgba(143, 170, 255, .035); }.auth-aside__inner { position: relative; z-index: 1; max-width: 450px; }.auth-aside .eyebrow { color: #8faaff; }.auth-aside h1 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: clamp(40px, 5vw, 67px); letter-spacing: -.08em; line-height: .98; }.auth-aside__inner > p:not(.eyebrow) { max-width: 360px; margin: 24px 0 0; color: #adbbce; font-size: 15px; line-height: 1.6; }.auth-aside__note { display: flex; align-items: flex-start; gap: 10px; margin-top: 40px; border-top: 1px solid #31445e; padding-top: 17px; color: #8faaff; font-size: 11px; line-height: 1.4; }.auth-aside__stamp { position: absolute; bottom: 31px; left: max(8vw, 60px); color: #566a85; font-family: "IBM Plex Mono", monospace; font-size: 10px; letter-spacing: .13em; }.auth-panel { display: flex; flex-direction: column; padding: 35px max(8vw, 70px); background: var(--surface); }.auth-panel__top { display: flex; align-items: center; justify-content: space-between; }.auth-panel__top .language-switch { margin-left: auto; }.auth-form { width: min(100%, 410px); margin: auto; }.auth-form__heading { display: flex; align-items: center; gap: 12px; margin-bottom: 34px; }.auth-form__icon { display: inline-grid; width: 43px; height: 43px; place-items: center; border-radius: 12px; background: var(--blue-soft); color: var(--blue); }.auth-form__heading h2 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 27px; letter-spacing: -.06em; }.auth-form__heading .eyebrow { margin-bottom: 5px; }.auth-form > label { margin-bottom: 15px; }.input-with-icon { position: relative; }.input-with-icon svg { position: absolute; top: 13px; left: 12px; color: var(--muted); }.input-with-icon input { padding-left: 38px; }.form-error { margin: -2px 0 12px; color: var(--coral); font-size: 11px; }.auth-form__switch { margin: 19px 0 0; color: var(--muted); font-size: 11px; text-align: center; }.auth-form__switch a { color: var(--blue); font-weight: 600; }.auth-panel__footer { margin-top: auto; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 9px; }.auth-panel__footer span { display: inline-flex; align-items: center; gap: 6px; } .center-page { display: grid; min-height: calc(100vh - 140px); place-items: center; padding: 40px 20px; }.center-card { display: grid; width: min(100%, 430px); justify-items: center; gap: 13px; border: 1px solid var(--line); border-radius: 18px; padding: 42px; background: var(--surface); box-shadow: var(--shadow); text-align: center; }.center-card--wide { width: min(100%, 520px); }.center-card__icon { display: inline-grid; width: 52px; height: 52px; place-items: center; border-radius: 15px; background: var(--blue-soft); color: var(--blue); }.center-card h1 { margin: 2px 0 0; font-family: "Space Grotesk", sans-serif; font-size: 30px; letter-spacing: -.065em; }.center-card > p:not(.eyebrow) { max-width: 320px; margin: 0 0 10px; color: var(--muted); font-size: 13px; line-height: 1.55; }.center-card .field-label { width: 100%; margin: 10px 0; text-align: left; } @keyframes timeline-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } @keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } @media (max-width: 1100px) { .sidebar { width: 215px; }.main-content { margin-left: 215px; }.content-topbar { padding: 0 25px; }.page-container { padding: 34px 25px 70px; }.landing-hero { gap: 25px; }.landing-hero__copy h1 { font-size: 60px; }.hero-orbit--one { width: 330px; height: 420px; }.hero-orbit--two { width: 400px; height: 250px; } } @media (max-width: 820px) { .sidebar { display: none; }.main-content { margin-left: 0; }.content-topbar { display: none; }.mobile-topbar { display: flex; height: 68px; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid var(--line); background: var(--surface); }.mobile-topbar__actions { display: flex; align-items: center; gap: 4px; }.mobile-topbar .org-switcher { min-height: 34px; padding: 0 7px; }.mobile-topbar .org-switcher select { width: 105px; font-size: 10px; }.content-scroll { min-height: calc(100vh - 132px); padding-bottom: 70px; }.mobile-bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(4, 1fr); height: 64px; border-top: 1px solid var(--line); background: rgba(255, 255, 255, .95); backdrop-filter: blur(16px); }.mobile-nav-link { display: grid; align-content: center; justify-items: center; gap: 4px; color: var(--muted); font-size: 9px; }.mobile-nav-link--active { color: var(--blue); }.page-container { padding: 28px 18px 50px; }.page-header { align-items: flex-start; flex-direction: column; gap: 16px; margin-bottom: 24px; }.page-header__actions { width: 100%; }.page-header__actions > .button { flex: 1; }.metrics-grid { grid-template-columns: 1fr 1fr; }.dashboard-grid, .detail-grid, .organization-grid, .account-layout { grid-template-columns: 1fr; }.quick-actions, .more-grid { grid-template-columns: 1fr; }.timeline-card { overflow-x: auto; }.timeline-card__header { min-width: 620px; }.timeline { min-width: 850px; }.timeline-card__legend { margin-right: 24px; }.table-head { display: none; }.table-row { grid-template-columns: 1fr auto; gap: 10px; padding: 15px 15px; }.table-row > span:nth-child(2), .table-row > span:nth-child(3), .table-row > strong, .table-row > .status-pill { grid-column: 2; justify-self: end; }.table-row__primary { grid-row: span 2; }.table-row > span:nth-child(2) { grid-row: 1; }.table-row > span:nth-child(3) { grid-row: 2; }.table-row > strong { grid-row: 3; }.table-row > .status-pill { grid-row: 4; }.row-actions { grid-row: 1; grid-column: 2; }.table-row--expense > span:nth-child(2), .table-row--expense > span:nth-child(3), .table-row--expense > strong, .table-row--expense > .status-pill { grid-column: 2; }.filter-bar { flex-wrap: wrap; }.search-field { max-width: none; flex-basis: 100%; }.select-field { flex: 1; }.organization-hero { align-items: flex-start; flex-wrap: wrap; }.organization-hero__status { width: 100%; margin-left: 66px; }.member-row__joined { display: none; }.landing { width: min(100% - 36px, 620px); }.landing-hero { grid-template-columns: 1fr; padding: 45px 0 65px; }.landing-hero__copy h1 { font-size: clamp(48px, 13vw, 73px); }.landing-hero__visual { min-height: 400px; margin-top: 15px; }.landing-signal > div:first-child { align-items: flex-start; flex-direction: column; gap: 20px; }.landing-signal h2 { font-size: 35px; }.landing-signal__grid { grid-template-columns: 1fr; gap: 4px; }.landing-signal__grid article { min-height: 165px; }.public-nav, .public-footer { width: calc(100% - 36px); }.public-nav { height: 70px; }.public-nav__actions .text-link { display: none; }.auth-layout { grid-template-columns: 1fr; min-height: calc(100vh - 70px); }.auth-aside { min-height: 345px; padding: 42px 25px; }.auth-aside h1 { max-width: 500px; font-size: 49px; }.auth-aside__stamp { bottom: 20px; left: 25px; }.auth-aside::after { right: -140px; bottom: -240px; }.auth-panel { min-height: 570px; padding: 25px; }.auth-form { margin: 45px auto; }.public-footer { align-items: flex-start; flex-direction: column; gap: 12px; }.stat-strip strong { font-size: 19px; } } +.mobile-topbar .notification-panel { position: fixed; top: 61px; right: 10px; left: 10px; width: auto; } @media (max-width: 480px) { .health-badge span { display: none; }.health-badge { padding: 6px; }.page-header h1 { font-size: 36px; }.metrics-grid { gap: 8px; }.metric-card { min-height: 136px; padding: 14px; }.metric-card strong { margin-top: 13px; font-size: 22px; }.metric-card p { font-size: 10px; }.dashboard-intro__trend { display: none; }.timeline-card { margin-right: -18px; margin-left: -18px; border-right: 0; border-left: 0; border-radius: 0; }.surface-card--table { margin-right: -1px; margin-left: -1px; }.stat-strip { overflow: hidden; }.stat-strip > div { padding: 13px 10px; }.stat-strip span { font-size: 8px; }.stat-strip strong { font-size: 15px; }.modal { padding: 19px; }.form-grid { grid-template-columns: 1fr; }.profile-card__identity { align-items: flex-start; flex-wrap: wrap; }.profile-card__identity .button { width: 100%; }.preference-row { align-items: flex-start; flex-direction: column; justify-content: center; padding: 12px 0; }.preference-row > select, .theme-pills, .preference-row > .button { align-self: flex-start; }.landing-hero__visual { min-height: 340px; }.hero-desk-card { width: 94%; }.hero-float--top { top: 25px; right: -3px; }.hero-float--bottom { bottom: 24px; left: -4px; }.hero-orbit--one { width: 290px; height: 350px; }.hero-orbit--two { width: 330px; height: 220px; }.hero-desk-card__balance { margin: 30px 0 25px; }.hero-desk-card__balance strong { font-size: 33px; } } -[data-theme="dark"] { --ink: #edf3ff; --ink-soft: #aab8cb; --muted: #8090a5; --paper: #0e1828; --surface: #142238; --line: #263750; --line-strong: #3b4d68; --blue-soft: #1d315b; --mint-soft: #133d3b; --amber-soft: #45341e; --coral-soft: #482733; --shadow: 0 18px 50px rgba(0, 0, 0, .25); } +[data-theme="dark"] { --ink: #edf3ff; --ink-soft: #aab8cb; --muted: #8090a5; --paper: #0e1828; --surface: #142238; --line: #263750; --line-strong: #3b4d68; --blue-soft: #1d315b; --mint-soft: #133d3b; --amber-soft: #45341e; --coral-soft: #482733; --ink-deep: #0c1626; --shadow: 0 18px 50px rgba(0, 0, 0, .25); } [data-theme="dark"] body, [data-theme="dark"] .sidebar, [data-theme="dark"] .content-topbar, [data-theme="dark"] .mobile-topbar { background: var(--paper); } [data-theme="dark"] .sidebar, [data-theme="dark"] .content-topbar { background: #101c2e; } [data-theme="dark"] .nav-link:hover, [data-theme="dark"] .compact-row:hover { background: #1b2a42; } [data-theme="dark"] .org-switcher, [data-theme="dark"] .button--secondary, [data-theme="dark"] .icon-button:hover, [data-theme="dark"] .search-field, [data-theme="dark"] .select-field, [data-theme="dark"] .modal-form input, [data-theme="dark"] .modal-form select, [data-theme="dark"] .field-label input, [data-theme="dark"] .auth-form input, [data-theme="dark"] .account-form input, [data-theme="dark"] .inline-form input, [data-theme="dark"] .preference-row select, [data-theme="dark"] .theme-pill, [data-theme="dark"] .health-badge button { background: var(--surface); color: var(--ink); } [data-theme="dark"] .health-badge, [data-theme="dark"] .sidebar__signal { background: #142b3c; border-color: #244d55; } [data-theme="dark"] .landing-hero__visual .hero-float { background: #172944; border-color: #2c4260; } +[data-theme="dark"] .metric-card--mint { background: linear-gradient(135deg, var(--surface) 36%, var(--mint-soft)); } +[data-theme="dark"] .metric-card--amber { background: linear-gradient(135deg, var(--surface) 36%, var(--amber-soft)); } +[data-theme="dark"] .meter { background: var(--line-strong); } +[data-theme="dark"] .status-pill--upcoming, [data-theme="dark"] .status-pill--pending { color: #9db4ff; } +[data-theme="dark"] .status-pill--due { color: #f0b45c; } +[data-theme="dark"] .status-pill--overdue, [data-theme="dark"] .status-pill--cancelled { color: #ff8f99; } +[data-theme="dark"] .status-pill--paid { color: #4fd6b8; } diff --git a/apps/frontend-v2/src/ui.tsx b/apps/frontend-v2/src/ui.tsx index 4a0d82f..b51b665 100644 --- a/apps/frontend-v2/src/ui.tsx +++ b/apps/frontend-v2/src/ui.tsx @@ -3,7 +3,6 @@ import { Link, NavLink, Outlet, useLocation, useNavigate, useSearchParams } from import { ArrowUpRight, BarChart3, - Bell, Building2, CalendarDays, ChevronDown, @@ -15,9 +14,11 @@ import { LayoutDashboard, LogOut, Menu, + Moon, MoreHorizontal, ReceiptText, Settings2, + SunMedium, UsersRound, WalletCards, X, @@ -28,11 +29,14 @@ import { formatCurrency } from "./format"; import { useAuth } from "./auth/auth-provider"; import { useOrganizationStore } from "./auth/auth-store"; import { useOrganizationsQuery } from "./hooks/use-queries"; +import { useTheme } from "./hooks/use-theme"; +import { NotificationBell } from "./notification-bell"; type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; export function BrandMark({ compact = false }: { compact?: boolean }) { - return bitfinance; + const { t } = useTranslation(); + return bitfinance; } export function Button({ variant = "primary", className = "", children, ...props }: ButtonHTMLAttributes & { variant?: ButtonVariant }) { @@ -48,7 +52,8 @@ export function Avatar({ initials, src, size = "md" }: { initials: string; src?: } export function StatusPill({ status }: { status: string }) { - const label = status.replaceAll("_", " "); + const { t } = useTranslation(); + const label = t(`statuses.${status}`, { defaultValue: status.replaceAll("_", " ") }); return {label}; } @@ -118,7 +123,8 @@ function PeriodPicker() { setOpen(false); }; - return
{open &&
Choose a periodDashboard data updates after applying.
{from > to &&

The end date must be on or after the start date.

}
}
; + const { t } = useTranslation(); + return
{open &&
{t("common.choosePeriod")}{t("common.periodUpdated")}
{from > to &&

{t("common.endDateError")}

}
}
; } export function SectionHeading({ title, description, action }: { title: string; description?: string; action?: ReactNode }) { @@ -134,6 +140,7 @@ export function EmptyState({ icon: Icon = FileText, title, description, action } } export function Modal({ title, description, onClose, children, wide = false }: { title: string; description?: string; onClose: () => void; children: ReactNode; wide?: boolean }) { + const { t } = useTranslation(); const ref = useRef(null); useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; @@ -141,11 +148,12 @@ export function Modal({ title, description, onClose, children, wide = false }: { ref.current?.focus(); return () => document.removeEventListener("keydown", handler); }, [onClose]); - return
{ if (event.target === event.currentTarget) onClose(); }}>
{description &&

{description}

}
{children}
; + return
{ if (event.target === event.currentTarget) onClose(); }}>
{description &&

{description}

}
{children}
; } export function ActionMenu({ onEdit, onPaid, onDelete, detailHref, canPay = false }: { onEdit: () => void; onPaid?: () => void; onDelete: () => void; detailHref?: string; canPay?: boolean }) { - return }>; + const { t } = useTranslation(); + return }>; } const LazyActionMenu = lazy(async () => { @@ -165,7 +173,15 @@ function OrganizationSwitcher() { const selectedId = useOrganizationStore((state) => state.selectedOrganizationId); const setSelectedId = useOrganizationStore((state) => state.setSelectedOrganizationId); const items = organizations.data ?? user?.organizations ?? []; - return ; + const { t } = useTranslation(); + return ; +} + +function ThemeSwitcher() { + const { t } = useTranslation(); + const { theme, setTheme } = useTheme(); + const label = t("common.theme"); + return setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? : }; } function UserMenu() { @@ -180,12 +196,12 @@ function UserMenu() { export function AppShell() { const { t } = useTranslation(); const location = useLocation(); - return
Live workspace / {location.pathname.includes("bills") ? t("nav.bills") : location.pathname.includes("expenses") ? t("nav.expenses") : location.pathname.includes("organization") ? t("nav.organization") : t("nav.overview")}
; + return
{t("common.liveWorkspace")} / {location.pathname.includes("bills") ? t("nav.bills") : location.pathname.includes("expenses") ? t("nav.expenses") : location.pathname.includes("organization") ? t("nav.organization") : t("nav.overview")}
; } export function PublicLayout({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); - return
{t("common.signIn")}{t("common.signUp")}
{children}
© 2026 BitFinance. A clearer view of your money.
; + return
{t("common.signIn")}{t("common.signUp")}
{children}
{t("home.footer")}
; } export function KpiSparkline({ values, color = "#2f5bea" }: { values: number[]; color?: string }) { @@ -206,7 +222,8 @@ export function PageContainer({ children }: { children: ReactNode }) { } export function MobileMenuButton({ onClick }: { onClick: () => void }) { - return ; + const { t } = useTranslation(); + return ; } export function DataIcon({ type }: { type: "bill" | "expense" | "budget" | "team" }) {