diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2d00997..d5f47984c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Added +* [Yavsc.Api, Yavsc.Server, Yavsc.Api.Client] Creation explicite et + idempotente d'une facture depuis un devis doublement signe, avec lignes, + total et parties figes. Historique des encaissements hors ligne attestes + par le prestataire et des transactions PayPal verifiees cote serveur ; + references uniques, cumul atomique et protection contre les ecritures + concurrentes. Routes documentees, client HTTP et migration associes. + * [Yavsc.Abstract, Yavsc.Server] Modele de solde des commandes facturables : montants dus et payes persistants, reste a payer et etats non paye, partiellement paye ou acquitte. L'acquittement depend des montants, @@ -20,6 +27,11 @@ ### Fixed +* [Facturation] Les commandes facturees et leur devis ne peuvent plus etre + supprimes via les routes de commande usuelles. Les additions distinguent + montant verse, solde restant et acquittement ; les anciennes routes de + generation exigent une facture etablie depuis un devis signe. + ## [1.0.8-rc27] - unstable ### Added diff --git a/doc/invoicing.md b/doc/invoicing.md index 3e23b4f68..949e7adf3 100644 --- a/doc/invoicing.md +++ b/doc/invoicing.md @@ -127,8 +127,8 @@ Implémentée sur le point d'entrée existant `IBillable` / La migration ajoute les montants sans reconstituer de paiements historiques : les anciennes commandes ont un dû inconnu et un payé nul. Un rapprochement explicite est nécessaire avant de les considérer comme acquittées. -La génération depuis le devis, la fixation du dû et l'alimentation du payé -par des transactions confirmées restent à brancher aux étapes suivantes. +La génération depuis le devis et l'alimentation du payé sont désormais +branchées par le service `InvoicingService` (étapes 2 et 4 ci-dessous). L'appel `SetPaymentAmounts` remplace un total cumulé ; il n'enregistre pas une transaction et ne doit pas être utilisé pour additionner un retour PayPal sans contrôle d'idempotence. @@ -142,9 +142,33 @@ Objectifs couverts : ### Étape 2 — brancher la génération de facture sur le devis signé -- vérifier que le devis est bien doublement validé, -- déclencher la facture depuis le contexte du devis, -- rattacher la facture au bon client / prestataire. +Implémentée par `POST /api/v1/invoices/from-estimate/{estimateId}` : + +- réservée au prestataire du devis (ou à un administrateur) ; +- exige les deux dates de validation **et** les deux signatures persistées, + non vides, associées aux bons signataires ; une date seule ne suffit pas ; +- exige une commande existante, dont le client et le prestataire correspondent + au devis ; les devis sans commande restent des devis, non des factures ; +- copie les lignes du devis, sa devise EUR, son total et ses parties dans + un instantané de facture sur la commande, daté côté serveur ; +- accepte les remises mais refuse un total négatif, les quantités non positives, + les prix infra-centime et les devis multidevises ; +- initialise le payé à zéro, sans assimiler un ancien checkout à un encaissement ; +- renvoie la même facture lors d'un nouvel appel pour le même devis, sans + changer le total ou effacer les paiements ; une autre facture ou un solde + historique préexistant sur la commande provoque un conflit de rapprochement. + +`GET /api/v1/invoices/{commandId}` expose l'instantané et l'historique aux +parties de la facture et aux administrateurs. Les statuts sont des chaînes +`Unpaid`, `PartiallyPaid` et `Paid`. Les lignes utilisées par `GetBillItems()` +sont celles de la facture figée, même si le devis est modifié ensuite. +Les commandes facturées et leurs devis ne sont plus supprimables par les +routes de commande concernées. Le changement de prestataire est bloqué sur +les routes usuelles de modification ; l'instantané reste la référence des parties. + +Cette création établit la facture métier et ses montants, **pas un PDF** : +le moteur historique de rendu documentaire reste distinct. Ses routes de +génération exigent désormais une facture établie. ### Étape 3 — intégrer le mode de contrat du prestataire @@ -154,9 +178,71 @@ Objectifs couverts : ### Étape 4 — enregistrer les paiements sans surinterpréter le statut -- enregister le paiement effectué, -- alimenter les montants payés et restants, -- ne pas considérer la facture comme totalement acquittée tant que le total n'est pas atteint. +Implémentée par deux sources d'encaissement, sans débit déclenché par ces routes : + +| Route POST sous `/api/v1/invoices/{commandId}` | Source de vérité | Accès | +| --- | --- | --- | +| `/payments/offline` | Attestation du prestataire : référence, montant, devise, date de réception | Prestataire ou administrateur | +| `/payments/paypal` | Réponse serveur PayPal `GetTransactionDetails` pour un identifiant de transaction | Parties ou administrateur | + +Le reçu hors ligne doit être strictement positif, en centimes EUR, avec une +référence non vide et une date non future. Il est attesté, pas vérifié auprès +de la banque. La référence est unique par prestataire ; le client ne peut +pas s'auto-déclarer acquitté. + +Pour PayPal, seul l'identifiant est accepté du client. Le serveur vérifie : + +- statut `Completed`, et non un simple `ACK=Success` ou un statut `Pending` ; +- identifiant exact, référence de facture `TypeCommande/Id` (`INVNUM`), + devise de la facture et compte marchand destinataire (`RECEIVERID`) ; +- montant brut reçu strictement positif et date retournés par PayPal. + +Le token de checkout historique n'est **pas** un identifiant de transaction. +Le retour historique `ConfirmPayment` ne constitue toujours pas une preuve +d'encaissement et n'alimente pas le solde. Cette intégration vérifie des +transactions déjà encaissées ; elle n'ajoute ni capture, ni webhook, ni +automatisation du retour navigateur. Le protocole NVP reprend le système +Classic déjà utilisé par le dépôt ; il est déprécié par PayPal et réservé +aux marchands existants compatibles. + +Chaque encaissement conserve sa référence, son origine, son montant, sa +devise, ses dates de réception/enregistrement et l'identifiant de l'acteur. +La référence PayPal est unique globalement : une même transaction ne peut +pas solder deux factures. Rejouer un reçu identique ne l'ajoute pas deux fois ; +réutiliser sa référence avec d'autres détails renvoie `409`. + +L'ajout au journal et la mise à jour du total payé sont sauvegardés dans la +même transaction EF relationnelle. Un jeton de concurrence sur la commande +empêche les pertes de mise à jour. En cas de conflit concurrent, la route +renvoie `409` : rejouer **la même référence et le même corps** permet de +réessayer sans double comptage. Un solde ne correspondant pas au journal +requiert un rapprochement, sans réinitialisation silencieuse. + +Le solde est calculé à partir des reçus cumulés. Un paiement partiel garde +la facture non acquittée ; un trop-perçu est conservé avec un restant nul. +Le texte de l'addition affiche payé, restant et statut. + +#### Configuration et utilisation + +Appliquer la migration `SignedEstimateInvoicing` avant déploiement. +Elle ajoute l'instantané, le jeton de concurrence et le journal `InvoicePayments`, +sans créer de facture ni de reçu pour les données historiques. + +La vérification PayPal utilise la section `Authentication:PayPal` : +`Mode` (`sandbox` ou `production`), `MerchantAccountId` et +`MerchantAccountUserName`. Une entrée de `Accounts` doit correspondre à ce +dernier via `ApiUsername`, avec `ApiPassword` et `Signature`. +Conserver les identifiants dans les secrets ou l'environnement, jamais dans +un fichier versionné. Sans configuration exploitable, la route renvoie `503` +sans enregistrer de paiement ; une panne ou une réponse invalide PayPal +renvoie `502`/`504`, sans modifier le solde. + +Le client partagé `InvoicesApiClient` expose les quatre opérations. +Le document `/openapi/invoicing.json` est réservé aux administrateurs en +développement, comme les autres documents OpenAPI du serveur. +Des exemples de requêtes sont fournis dans `src/Yavsc.Api/Invoicing.http` ; +adapter l'adresse du serveur et les jetons. Aucun écran PostIt n'est ajouté +dans ce lot. ### Étape 5 — finaliser l'état acquitté @@ -164,8 +250,32 @@ Objectifs couverts : - si `totalPaye >= totalDu`, alors passer la facture en état acquittée, - envoyer les notifications / documents associés au cas échéant. +Le calcul du statut est branché aux encaissements. Les notifications finales, +le rendu documentaire, les remboursements/annulations de reçus et les +rapprochements historiques restent hors de ce lot, tout comme l'étape 3. +Un remboursement ou litige ultérieur n'est pas synchronisé automatiquement : +ne pas utiliser ce journal d'encaissements comme gestion complète des +remboursements PayPal. + ## Tests à couvrir +Les tests implémentés du backend s'exécutent avec le runner MTP du dépôt : + +```bash +dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \ + --filter-class '*InvoiceApiTests' --filter-class '*HairCutQueryApiControllerTests' +dotnet test --project src/test/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj \ + --filter-class '*PaymentSummaryTests' --filter-class '*PayPalTransactionVerifierTests' +dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ + --filter-class '*InvoicesApiClientTests' +``` + +Les tests HTTP utilisent la base relationnelle isolée de la fixture. +Ils vérifient notamment qu'un ajout concurrent perdant est entièrement +annulé (journal et total), puis rejouable. Les tests PayPal utilisent des +réponses HTTP simulées, sans identifiants réels ni appel au prestataire. +Ils ne remplacent pas une recette sandbox sur un compte Classic compatible. + Le flux doit être validé par des tests ciblés sur : - devis signé par les deux parties → génération d'une facture non acquittée, diff --git a/src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs b/src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs new file mode 100644 index 000000000..c8aa52c0c --- /dev/null +++ b/src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs @@ -0,0 +1,84 @@ +using System.Net.Http; +using Yavsc.Api.Client; +using Yavsc.Billing; + +namespace PostIt.Tests; + +public sealed class InvoicesApiClientTests +{ + [Fact] + public async Task Client_maps_invoice_operations_and_forwards_cancellation() + { + var api = new RecordingApi(); + var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/"); + var ct = TestContext.Current.CancellationToken; + + await client.GenerateAsync(7, ct); + Assert.Equal(HttpMethod.Post, api.Method); + Assert.Equal("https://business.example/api/v1/invoices/from-estimate/7", api.Path); + Assert.Null(api.Body); + Assert.Equal(ct, api.Cancellation); + + await client.GetAsync(42, ct); + Assert.Equal(HttpMethod.Get, api.Method); + Assert.Equal("https://business.example/api/v1/invoices/42", api.Path); + + var receipt = new RecordInvoicePaymentRequest + { + Reference = "bank-1", Amount = 30m, Currency = "EUR", + ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(-1) + }; + await client.RecordOfflineAsync(42, receipt, ct); + Assert.Equal(HttpMethod.Post, api.Method); + Assert.Equal("https://business.example/api/v1/invoices/42/payments/offline", api.Path); + Assert.Same(receipt, api.Body); + + await client.VerifyPayPalAsync(42, "TX-1", ct); + Assert.Equal(HttpMethod.Post, api.Method); + Assert.Equal("https://business.example/api/v1/invoices/42/payments/paypal", api.Path); + Assert.Equal("TX-1", Assert.IsType(api.Body).TransactionId); + Assert.Equal(ct, api.Cancellation); + } + + [Fact] + public async Task Invalid_arguments_fail_before_transport() + { + var api = new RecordingApi(); + var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/"); + var ct = TestContext.Current.CancellationToken; + await Assert.ThrowsAsync(() => client.GenerateAsync(0, ct)); + await Assert.ThrowsAsync(() => client.GetAsync(-1, ct)); + await Assert.ThrowsAsync(() => client.RecordOfflineAsync(42, null!, ct)); + await Assert.ThrowsAsync(() => client.VerifyPayPalAsync(42, "", ct)); + Assert.Null(api.Path); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? Method { get; private set; } + public string? Path { get; private set; } + public object? Body { get; private set; } + public CancellationToken Cancellation { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Method = method; + Path = path; + Body = body; + Cancellation = ct; + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => throw new NotSupportedException(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index d764507de..e95aa3c15 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -34,6 +34,7 @@ public static class ServiceCollectionHelpers () => settings.Authentication?.Authority); var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); + var invoicesClient = new InvoicesApiClient(api, () => settings.ApiUrl); var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl); var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl); @@ -104,6 +105,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(activityClient); services.AddSingleton(billingClient); services.AddSingleton(estimateClient); + services.AddSingleton(invoicesClient); services.AddSingleton(frontClient); services.AddSingleton(resourceUsageClient); services.AddSingleton(administrationClient); diff --git a/src/Yavsc.Abstract/Billing/InvoiceDtos.cs b/src/Yavsc.Abstract/Billing/InvoiceDtos.cs new file mode 100644 index 000000000..38d0d5fea --- /dev/null +++ b/src/Yavsc.Abstract/Billing/InvoiceDtos.cs @@ -0,0 +1,38 @@ +#nullable enable + +using System.ComponentModel.DataAnnotations; + +namespace Yavsc.Billing; + +/// A frozen invoice line copied from the signed estimate. +public sealed record InvoiceLineResponse(string Name, string Description, int Count, decimal UnitaryCost); + +/// An invoice and its cumulative settlement balance. +public sealed record InvoiceResponse( + long CommandId, long EstimateId, string Reference, string ClientId, string PerformerId, + DateTimeOffset CreatedAtUtc, string Currency, decimal AmountDue, decimal AmountPaid, + decimal AmountRemaining, string PaymentStatus, bool IsAcquitted, + IReadOnlyList Lines, IReadOnlyList Payments); + +/// An immutable recorded receipt, not an instruction to debit the client. +public sealed record InvoicePaymentResponse( + long Id, string Source, string Reference, decimal Amount, string Currency, + DateTimeOffset ReceivedAtUtc, DateTimeOffset RecordedAtUtc, string RecordedBy); + +/// A receipt attested by the invoice's performer. +public sealed record RecordInvoicePaymentRequest +{ + [Required, MaxLength(128)] + public required string Reference { get; init; } + public required decimal Amount { get; init; } + [Required, StringLength(3, MinimumLength = 3)] + public required string Currency { get; init; } + public required DateTimeOffset ReceivedAtUtc { get; init; } +} + +/// A PayPal transaction identifier to verify on the server; no client amount is trusted. +public sealed record VerifyInvoicePayPalRequest +{ + [Required, MaxLength(128)] + public required string TransactionId { get; init; } +} diff --git a/src/Yavsc.Api.Client/InvoicesApiClient.cs b/src/Yavsc.Api.Client/InvoicesApiClient.cs new file mode 100644 index 000000000..b2d62852d --- /dev/null +++ b/src/Yavsc.Api.Client/InvoicesApiClient.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Billing; + +namespace Yavsc.Api.Client; + +public sealed class InvoicesApiClient +{ + private readonly IYavscApiClient _api; + private readonly Func _businessBaseAddress; + + public InvoicesApiClient(IYavscApiClient api, Func businessBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress)); + } + + public Task GenerateAsync(long estimateId, CancellationToken ct = default) + => CallAsync(HttpMethod.Post, $"from-estimate/{PositiveId(estimateId)}", null, ct); + + public Task GetAsync(long commandId, CancellationToken ct = default) + => CallAsync(HttpMethod.Get, $"{PositiveId(commandId)}", null, ct); + + public Task RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct = default) + => CallAsync(HttpMethod.Post, $"{PositiveId(commandId)}/payments/offline", + request ?? throw new ArgumentNullException(nameof(request)), ct); + + public Task VerifyPayPalAsync(long commandId, string transactionId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(transactionId); + return CallAsync(HttpMethod.Post, $"{PositiveId(commandId)}/payments/paypal", + new VerifyInvoicePayPalRequest { TransactionId = transactionId }, ct); + } + + private Task CallAsync(HttpMethod method, string path, object? body, CancellationToken ct) + { + var baseAddress = _businessBaseAddress(); + ArgumentException.ThrowIfNullOrWhiteSpace(baseAddress); + return _api.CallAsync(method, new Uri(new Uri(baseAddress, UriKind.Absolute), $"invoices/{path}").ToString(), + body: body, ct: ct); + } + + private static long PositiveId(long id) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id); + return id; + } +} diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index 5f7f8e057..6d4f77d89 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers { return new ChallengeResult(); } + if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null }) + return Conflict(new { Error = "Create the invoice from a signed estimate first." }); Response.ContentType = "text/x-tex"; return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true }); } @@ -103,6 +105,10 @@ namespace Yavsc.ApiControllers return this.NotFound(); } logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString()); + if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice) + return Conflict(new { Error = "Create the invoice from a signed estimate first." }); + if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName)) + return Forbid(); return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } ); } diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs index 92e89e28e..ba351ca83 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs @@ -193,6 +193,8 @@ namespace Yavsc.Controllers return BadRequest(ModelState); } } + if (_context.NominativeServiceCommands.Any(command => command.InvoiceEstimateId == id)) + return Conflict(new { Error = "An invoiced estimate cannot be deleted." }); _context.Estimates.Remove(estimate); _context.SaveChanges(User.GetUserId()); diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs index 7f445fdd9..bed63d895 100644 --- a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs @@ -154,6 +154,9 @@ public class HairCutQueryApiController : Controller return BadRequest(ModelState); } + if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId) + return Conflict(new { Error = "An invoiced command cannot change performer." }); + existing.ActivityCode = query.ActivityCode; existing.PerformerId = query.PerformerId; existing.Consent = query.Consent; @@ -187,6 +190,8 @@ public class HairCutQueryApiController : Controller return Forbid(); } + if (query.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.HairCutQueries.Remove(query); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); diff --git a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs index 3b33c4160..c6d4205bd 100644 --- a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs @@ -170,6 +170,8 @@ public class HairMultiCutQueryApiController : Controller return BadRequest(ModelState); } + if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId) + return Conflict(new { Error = "An invoiced command cannot change performer." }); _context.RemoveRange(existing.Prestations); existing.ActivityCode = query.ActivityCode; existing.PerformerId = query.PerformerId; @@ -203,6 +205,8 @@ public class HairMultiCutQueryApiController : Controller return Forbid(); } + if (query.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.RemoveRange(query.Prestations); _context.HairMultiCutQueries.Remove(query); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); diff --git a/src/Yavsc.Api/Controllers/Business/InvoicesController.cs b/src/Yavsc.Api/Controllers/Business/InvoicesController.cs new file mode 100644 index 000000000..737cef2a9 --- /dev/null +++ b/src/Yavsc.Api/Controllers/Business/InvoicesController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Yavsc.Billing; +using Yavsc.Server.Helpers; +using Yavsc.Services; + +namespace Yavsc.ApiControllers; + +[Authorize, ApiController, Route(Constants.APIPrefix + "/invoices")] +[Produces("application/json")] +[ApiExplorerSettings(GroupName = "invoicing")] +public sealed class InvoicesController(IInvoicingService invoices, ILogger logger) : ControllerBase +{ + /// Creates a frozen invoice from an estimate signed by both parties, or returns its existing invoice. + [HttpPost("from-estimate/{estimateId:long}")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public Task> Generate(long estimateId, CancellationToken ct) + => ExecuteAsync(() => invoices.GenerateAsync(estimateId, User.GetUserId(), IsAdmin, ct), created: true); + + /// Returns the invoice, balance and receipt history to its parties. + [HttpGet("{commandId:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task> Get(long commandId, CancellationToken ct) + => ExecuteAsync(() => invoices.GetAsync(commandId, User.GetUserId(), IsAdmin, ct)); + + /// Records an offline receipt attested by the performer, idempotently by reference. + [HttpPost("{commandId:long}/payments/offline")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public Task> RecordOffline(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct) + => ExecuteAsync(() => invoices.RecordOfflineAsync(commandId, request, User.GetUserId(), IsAdmin, ct)); + + /// Verifies a completed PayPal transaction server-side before recording its receipt. + [HttpPost("{commandId:long}/payments/paypal")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public Task> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct) + => ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct)); + + private bool IsAdmin => User.IsInRole(Constants.AdminGroupName); + + private async Task> ExecuteAsync(Func> action, bool created = false) + { + try + { + var invoice = await action(); + return created + ? CreatedAtAction(nameof(Get), new { commandId = invoice.CommandId }, invoice) + : Ok(invoice); + } + catch (InvoiceOperationException exception) + { + logger.LogWarning("Invoice operation rejected ({StatusCode}): {Reason}", exception.StatusCode, exception.Message); + return Problem(statusCode: exception.StatusCode, title: exception.Message); + } + } +} diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index 6ecf89dd7..0b5b08222 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -162,6 +162,9 @@ public class RdvQueryApiController : Controller return Forbid(); } + if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId) + return Conflict(new { Error = "An invoiced command cannot change performer." }); + existing.ActivityCode = query.ActivityCode; existing.PerformerId = query.PerformerId; existing.Consent = query.Consent; @@ -224,6 +227,8 @@ public class RdvQueryApiController : Controller return Forbid(); } + if (query.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.RdvQueries.Remove(query); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); diff --git a/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs b/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs index cdffae4a3..f9816e3c9 100644 --- a/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs +++ b/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs @@ -88,9 +88,22 @@ namespace Yavsc.ApiControllers return BadRequest(); } + var persisted = await _context.HairCutQueries.AsNoTracking().SingleOrDefaultAsync(q => q.Id == id); + if (persisted is null) return NotFound(); + if (persisted.InvoiceEstimateId.HasValue + && (persisted.ClientId != hairCutQuery.ClientId || persisted.PerformerId != hairCutQuery.PerformerId)) + return Conflict(new { Error = "An invoiced command cannot change parties." }); _context.Entry(hairCutQuery).State = EntityState.Modified; + _context.Entry(hairCutQuery).Property(q => q.PaymentRevision).OriginalValue = persisted.PaymentRevision; _context.Entry(hairCutQuery).Property(q => q.AmountDue).IsModified = false; _context.Entry(hairCutQuery).Property(q => q.AmountPaid).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoiceEstimateId).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoicedAtUtc).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoiceCurrency).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoiceClientId).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoicePerformerId).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.InvoiceLinesJson).IsModified = false; + _context.Entry(hairCutQuery).Property(q => q.PaymentRevision).IsModified = false; try { @@ -206,6 +219,8 @@ namespace Yavsc.ApiControllers return NotFound(); } + if (hairCutQuery.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.HairCutQueries.Remove(hairCutQuery); await _context.SaveChangesAsync(); diff --git a/src/Yavsc.Api/Invoicing.http b/src/Yavsc.Api/Invoicing.http new file mode 100644 index 000000000..f9ed862e6 --- /dev/null +++ b/src/Yavsc.Api/Invoicing.http @@ -0,0 +1,51 @@ +@baseUrl = https://localhost:5001/api/v1 +@providerToken = replace-with-provider-access-token +@clientToken = replace-with-client-access-token +@estimateId = 1 +@commandId = 1 +@paypalTransactionId = replace-with-completed-paypal-transaction-id + +### Create an invoice from an estimate with both persisted signatures (performer) +POST {{baseUrl}}/invoices/from-estimate/{{estimateId}} +Authorization: Bearer {{providerToken}} + +### Read its frozen lines, balance and receipt history (either party) +GET {{baseUrl}}/invoices/{{commandId}} +Authorization: Bearer {{clientToken}} + +### Record an offline receipt (performer); replay the exact body to test idempotence +POST {{baseUrl}}/invoices/{{commandId}}/payments/offline +Authorization: Bearer {{providerToken}} +Content-Type: application/json + +{ + "reference": "bank-transfer-2026-001", + "amount": 30.00, + "currency": "EUR", + "receivedAtUtc": "2026-10-19T12:00:00Z" +} + +### Verify an already captured PayPal transaction (either party); not a checkout token +POST {{baseUrl}}/invoices/{{commandId}}/payments/paypal +Authorization: Bearer {{clientToken}} +Content-Type: application/json + +{ + "transactionId": "{{paypalTransactionId}}" +} + +### Missing invoice -> 404 +GET {{baseUrl}}/invoices/999999999 +Authorization: Bearer {{providerToken}} + +### A client cannot attest an offline receipt -> 403 +POST {{baseUrl}}/invoices/{{commandId}}/payments/offline +Authorization: Bearer {{clientToken}} +Content-Type: application/json + +{ + "reference": "not-authorized", + "amount": 30.00, + "currency": "EUR", + "receivedAtUtc": "2026-10-19T12:00:00Z" +} diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index f3e22f57a..9ea2f7354 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -72,6 +72,7 @@ internal class Program // the document-generation flow and BillingController. services.Configure(builder.Configuration.GetSection("Site")); services.AddPodcasts(builder.Configuration); + services.AddInvoicing(builder.Configuration); // accepts any access token issued by identity server services.AddAuthentication("Bearer") diff --git a/src/Yavsc.Api/Services/InvoicingServiceCollectionExtensions.cs b/src/Yavsc.Api/Services/InvoicingServiceCollectionExtensions.cs new file mode 100644 index 000000000..9601a48a0 --- /dev/null +++ b/src/Yavsc.Api/Services/InvoicingServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Yavsc.Services; + +namespace Yavsc.Api.Services; + +public static class InvoicingServiceCollectionExtensions +{ + public static IServiceCollection AddInvoicing(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.Configure(configuration.GetSection("Authentication:PayPal")); + services.AddHttpClient(client => + client.Timeout = TimeSpan.FromSeconds(30)); + services.AddOpenApi("invoicing", options => + options.ShouldInclude = description => description.GroupName == "invoicing"); + return services; + } +} diff --git a/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs b/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs index 2b981c600..bb0568f43 100644 --- a/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs +++ b/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs @@ -147,6 +147,8 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (query.ClientId != uid) return new ChallengeResult(); + if (query.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.HairCutQueries.Remove(query); await _context.SaveChangesAsync(); return await Index(); diff --git a/src/Yavsc.Org/Controllers/IT/ProjectController.cs b/src/Yavsc.Org/Controllers/IT/ProjectController.cs index 965530f35..590152fe1 100644 --- a/src/Yavsc.Org/Controllers/IT/ProjectController.cs +++ b/src/Yavsc.Org/Controllers/IT/ProjectController.cs @@ -158,6 +158,8 @@ namespace Yavsc.Controllers public async Task DeleteConfirmed(long id) { Project project = await _context.Project.SingleAsync(m => m.Id == id); + if (project.InvoiceEstimateId.HasValue) + return Conflict(new { Error = "An invoiced command cannot be deleted." }); _context.Project.Remove(project); await _context.SaveChangesAsync(); return RedirectToAction("Index"); diff --git a/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.Designer.cs b/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.Designer.cs new file mode 100644 index 000000000..f3bd54112 --- /dev/null +++ b/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.Designer.cs @@ -0,0 +1,4966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Yavsc.Models; + +#nullable disable + +namespace Yavsc.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261019184909_SignedEstimateInvoicing")] + partial class SignedEstimateInvoicing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("AllowedAccessTokenSigningAlgorithms") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiResources"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("ApiResourceId1") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("ApiResourceId1"); + + b.ToTable("ApiResourceClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("ApiResourceId1") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("ApiResourceId1"); + + b.ToTable("ApiResourceProperties"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("ApiResourceId1") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("ApiResourceId1"); + + b.ToTable("ApiResourceScopes"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("ApiResourceId1") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("ApiResourceId1"); + + b.ToTable("ApiResourceSecrets"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Emphasize") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("ApiScopes"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("ScopeId1") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("ScopeId1"); + + b.ToTable("ApiScopeClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("ScopeId1") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("ScopeId1"); + + b.ToTable("ApiScopeProperties"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("AbsoluteRefreshTokenLifetime") + .HasColumnType("integer"); + + b.Property("AccessTokenLifetime") + .HasColumnType("integer"); + + b.Property("AccessTokenType") + .HasColumnType("integer"); + + b.Property("AllowAccessTokensViaBrowser") + .HasColumnType("boolean"); + + b.Property("AllowOfflineAccess") + .HasColumnType("boolean"); + + b.Property("AllowPlainTextPkce") + .HasColumnType("boolean"); + + b.Property("AllowRememberConsent") + .HasColumnType("boolean"); + + b.Property("AllowedIdentityTokenSigningAlgorithms") + .HasColumnType("text"); + + b.Property("AlwaysIncludeUserClaimsInIdToken") + .HasColumnType("boolean"); + + b.Property("AlwaysSendClientClaims") + .HasColumnType("boolean"); + + b.Property("AuthorizationCodeLifetime") + .HasColumnType("integer"); + + b.Property("BackChannelLogoutSessionRequired") + .HasColumnType("boolean"); + + b.Property("BackChannelLogoutUri") + .HasColumnType("text"); + + b.Property("ClientClaimsPrefix") + .HasColumnType("text"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ClientName") + .HasColumnType("text"); + + b.Property("ClientUri") + .HasColumnType("text"); + + b.Property("ConsentLifetime") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DeviceCodeLifetime") + .HasColumnType("integer"); + + b.Property("EnableLocalLogin") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FrontChannelLogoutSessionRequired") + .HasColumnType("boolean"); + + b.Property("FrontChannelLogoutUri") + .HasColumnType("text"); + + b.Property("IdentityTokenLifetime") + .HasColumnType("integer"); + + b.Property("IncludeJwtId") + .HasColumnType("boolean"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone"); + + b.Property("LogoUri") + .HasColumnType("text"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("PairWiseSubjectSalt") + .HasColumnType("text"); + + b.Property("ProtocolType") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiration") + .HasColumnType("integer"); + + b.Property("RefreshTokenUsage") + .HasColumnType("integer"); + + b.Property("RequireClientSecret") + .HasColumnType("boolean"); + + b.Property("RequireConsent") + .HasColumnType("boolean"); + + b.Property("RequirePkce") + .HasColumnType("boolean"); + + b.Property("RequireRequestObject") + .HasColumnType("boolean"); + + b.Property("SlidingRefreshTokenLifetime") + .HasColumnType("integer"); + + b.Property("UpdateAccessTokenClaimsOnRefresh") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCodeType") + .HasColumnType("text"); + + b.Property("UserSsoLifetime") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientCorsOrigin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("ClientId1") + .HasColumnType("integer"); + + b.Property("Origin") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ClientId1"); + + b.ToTable("ClientCorsOrigins"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("GrantType") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientGrantTypes"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientIdPRestriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("ClientId1") + .HasColumnType("integer"); + + b.Property("Provider") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ClientId1"); + + b.ToTable("ClientIdPRestrictions"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("ClientId1") + .HasColumnType("integer"); + + b.Property("PostLogoutRedirectUri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ClientId1"); + + b.ToTable("ClientPostLogoutRedirectUris"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("ClientId1") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ClientId1"); + + b.ToTable("ClientProperties"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("RedirectUri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientRedirectUris"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientScopes"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("ClientId1") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ClientId1"); + + b.ToTable("ClientSecrets"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.DeviceFlowCodes", b => + { + b.Property("UserCode") + .HasColumnType("text"); + + b.Property("DeviceCode") + .HasColumnType("text"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("SubjectId") + .HasColumnType("text"); + + b.HasKey("UserCode", "DeviceCode"); + + b.ToTable("DeviceFlowCodes"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Emphasize") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("IdentityResources"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResourceClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityResourceId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IdentityResourceId"); + + b.ToTable("IdentityResourceClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResourceProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityResourceId") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IdentityResourceId"); + + b.ToTable("IdentityResourceProperties"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.PersistedGrant", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConsumedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("SubjectId") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("PersistedGrants"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Yavsc.Abstract.Identity.ClientProviderInfo", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("BillingAddressId") + .HasColumnType("bigint"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("ClientProviderInfo"); + }); + + modelBuilder.Entity("Yavsc.Abstract.Models.Messaging.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Target") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("body") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("click_action") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("color") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("icon") + .ValueGeneratedOnAdd() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasDefaultValue("exclam"); + + b.Property("sound") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("tag") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("title") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.Ban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.ToTable("Ban"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.BlackListed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("UserId"); + + b.ToTable("BlackListed"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.CircleAuthorizationToBlogPost", b => + { + b.Property("CircleId") + .HasColumnType("bigint"); + + b.Property("BlogPostId") + .HasColumnType("bigint"); + + b.HasKey("CircleId", "BlogPostId"); + + b.HasIndex("BlogPostId"); + + b.ToTable("CircleAuthorizationToBlogPost"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.CircleAuthorizationToFile", b => + { + b.Property("CircleId") + .HasColumnType("bigint"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Access") + .HasColumnType("smallint"); + + b.HasKey("CircleId", "Path", "OwnerId"); + + b.HasIndex("OwnerId"); + + b.ToTable("CircleAuthorizationToFile"); + }); + + modelBuilder.Entity("Yavsc.Models.AccountBalance", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ContactCredits") + .HasColumnType("bigint"); + + b.Property("Credits") + .HasColumnType("numeric"); + + b.HasKey("UserId"); + + b.ToTable("BankStatus"); + }); + + modelBuilder.Entity("Yavsc.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AllowMonthlyEmail") + .HasColumnType("boolean"); + + b.Property("Avatar") + .ValueGeneratedOnAdd() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasDefaultValue("/images/Users/icon_user.png"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DedicatedGoogleCalendar") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("DiskQuota") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(524288000L); + + b.Property("DiskUsage") + .HasColumnType("bigint"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxFileSize") + .HasColumnType("bigint"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PostalAddressId") + .HasColumnType("bigint"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasAlternateKey("Email"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PostalAddressId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Yavsc.Models.BalanceImpact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BalanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExecDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Impact") + .HasColumnType("numeric"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BalanceId"); + + b.ToTable("BalanceImpact"); + }); + + modelBuilder.Entity("Yavsc.Models.Bank.BankIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("BIC") + .IsRequired() + .HasColumnType("text"); + + b.Property("BankCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("BankedKey") + .HasColumnType("integer"); + + b.Property("IBAN") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("WicketCode") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("BankIdentity"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.CommandLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("Currency") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("EstimateId") + .HasColumnType("bigint"); + + b.Property("EstimateTemplateId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UnitaryCost") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.HasIndex("EstimateTemplateId"); + + b.ToTable("CommandLine"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttachedFilesString") + .IsRequired() + .HasColumnType("text"); + + b.Property("AttachedGraphicsString") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientValidationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CommandId") + .HasColumnType("bigint"); + + b.Property("CommandType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderValidationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("CommandId"); + + b.HasIndex("OwnerId"); + + b.ToTable("Estimates"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b => + { + b.Property("FileId") + .HasColumnType("bigint"); + + b.Property("EstimateId") + .HasColumnType("bigint"); + + b.HasKey("FileId", "EstimateId"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateAttachedFiles"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EstimateTemplates"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.ExceptionSIREN", b => + { + b.Property("SIREN") + .HasColumnType("text"); + + b.HasKey("SIREN"); + + b.ToTable("ExceptionsSIREN"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CommandId") + .HasColumnType("bigint"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReferenceScope") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CommandId"); + + b.HasIndex("Source", "ReferenceScope", "Reference") + .IsUnique(); + + b.ToTable("InvoicePayments"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("AmountDue") + .HasColumnType("numeric"); + + b.Property("AmountPaid") + .ValueGeneratedOnAdd() + .HasColumnType("numeric") + .HasDefaultValue(0m); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Consent") + .HasColumnType("boolean"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("InvoiceClientId") + .HasColumnType("text"); + + b.Property("InvoiceCurrency") + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("InvoiceEstimateId") + .HasColumnType("bigint"); + + b.Property("InvoiceLinesJson") + .HasColumnType("text"); + + b.Property("InvoicePerformerId") + .HasColumnType("text"); + + b.Property("InvoicedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentId") + .HasColumnType("text"); + + b.Property("PaymentRevision") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")); + + b.Property("PerformerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provisional") + .HasColumnType("numeric"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserCreated") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserModified") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActivityCode"); + + b.HasIndex("ClientId"); + + b.HasIndex("InvoiceEstimateId") + .IsUnique(); + + b.HasIndex("PaymentId"); + + b.HasIndex("PerformerId"); + + b.ToTable("NominativeServiceCommands"); + + b.HasDiscriminator("Discriminator").HasValue("NominativeServiceCommand"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b => + { + b.Property("FileId") + .HasColumnType("bigint"); + + b.Property("CommandId") + .HasColumnType("bigint"); + + b.HasKey("FileId", "CommandId"); + + b.HasIndex("CommandId"); + + b.ToTable("QueryAttachedFiles"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CoordinateMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(10000); + + b.Property("EstimateId") + .HasColumnType("bigint"); + + b.Property("SignerId") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection("Strokes") + .IsRequired() + .HasColumnType("integer[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("EstimateId", "Type") + .IsUnique(); + + b.ToTable("Signatures"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b => + { + b.Property("FileId") + .HasColumnType("bigint"); + + b.Property("PostId") + .HasColumnType("bigint"); + + b.HasKey("FileId", "PostId"); + + b.HasIndex("PostId"); + + b.ToTable("BlogAttachedFiles"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Article") + .HasMaxLength(56224) + .HasColumnType("character varying(56224)"); + + b.Property("AuthorId") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Photo") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("BlogSpot"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogTag", b => + { + b.Property("PostId") + .HasColumnType("bigint"); + + b.Property("TagId") + .HasColumnType("bigint"); + + b.HasKey("PostId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("BlogTag"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Article") + .IsRequired() + .HasColumnType("text"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.Property("ReceiverId") + .HasColumnType("bigint"); + + b.Property("UserCreated") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserModified") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ReceiverId"); + + b.ToTable("Comment"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("UploadedFiles"); + }); + + modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => + { + b.Property("PostId") + .HasColumnType("bigint"); + + b.HasKey("PostId"); + + b.ToTable("blogSpotPublications"); + }); + + modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b => + { + b.Property("OwnerId") + .HasColumnType("text"); + + b.HasKey("OwnerId"); + + b.ToTable("Schedule"); + }); + + modelBuilder.Entity("Yavsc.Models.Calendar.ScheduledEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("Reccurence") + .HasColumnType("integer"); + + b.Property("ScheduleOwnerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScheduleOwnerId"); + + b.HasIndex("PeriodStart", "PeriodEnd"); + + b.ToTable("ScheduledEvent"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatConnection", b => + { + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("ApplicationUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Connected") + .HasColumnType("boolean"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("ConnectionId"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("ChatConnection"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatRoom", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestJoinPart") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Topic") + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Name"); + + b.HasIndex("OwnerId"); + + b.ToTable("ChatRoom"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatRoomAccess", b => + { + b.Property("ChannelName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("ChannelName", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ChatRoomAccess"); + }); + + modelBuilder.Entity("Yavsc.Models.Cratie.Option", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("CodeScrutin") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Code", "CodeScrutin"); + + b.ToTable("Option"); + }); + + modelBuilder.Entity("Yavsc.Models.Drawing.Color", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Blue") + .HasColumnType("smallint"); + + b.Property("Green") + .HasColumnType("smallint"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Red") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("Color"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.BrusherProfile", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ActionDistance") + .HasColumnType("integer"); + + b.Property("CarePrice") + .HasColumnType("numeric"); + + b.Property("FlatFeeDiscount") + .HasColumnType("numeric"); + + b.Property("HalfBalayagePrice") + .HasColumnType("numeric"); + + b.Property("HalfBrushingPrice") + .HasColumnType("numeric"); + + b.Property("HalfColorPrice") + .HasColumnType("numeric"); + + b.Property("HalfDefrisPrice") + .HasColumnType("numeric"); + + b.Property("HalfFoldingPrice") + .HasColumnType("numeric"); + + b.Property("HalfMechPrice") + .HasColumnType("numeric"); + + b.Property("HalfMultiColorPrice") + .HasColumnType("numeric"); + + b.Property("HalfPermanentPrice") + .HasColumnType("numeric"); + + b.Property("KidCutPrice") + .HasColumnType("numeric"); + + b.Property("LongBalayagePrice") + .HasColumnType("numeric"); + + b.Property("LongBrushingPrice") + .HasColumnType("numeric"); + + b.Property("LongColorPrice") + .HasColumnType("numeric"); + + b.Property("LongDefrisPrice") + .HasColumnType("numeric"); + + b.Property("LongFoldingPrice") + .HasColumnType("numeric"); + + b.Property("LongMechPrice") + .HasColumnType("numeric"); + + b.Property("LongMultiColorPrice") + .HasColumnType("numeric"); + + b.Property("LongPermanentPrice") + .HasColumnType("numeric"); + + b.Property("ManBrushPrice") + .HasColumnType("numeric"); + + b.Property("ManCutPrice") + .HasColumnType("numeric"); + + b.Property("ScheduleOwnerId") + .HasColumnType("text"); + + b.Property("ShampooPrice") + .HasColumnType("numeric"); + + b.Property("ShortBalayagePrice") + .HasColumnType("numeric"); + + b.Property("ShortBrushingPrice") + .HasColumnType("numeric"); + + b.Property("ShortColorPrice") + .HasColumnType("numeric"); + + b.Property("ShortDefrisPrice") + .HasColumnType("numeric"); + + b.Property("ShortFoldingPrice") + .HasColumnType("numeric"); + + b.Property("ShortMechPrice") + .HasColumnType("numeric"); + + b.Property("ShortMultiColorPrice") + .HasColumnType("numeric"); + + b.Property("ShortPermanentPrice") + .HasColumnType("numeric"); + + b.Property("WomenHalfCutPrice") + .HasColumnType("numeric"); + + b.Property("WomenLongCutPrice") + .HasColumnType("numeric"); + + b.Property("WomenShortCutPrice") + .HasColumnType("numeric"); + + b.HasKey("UserId"); + + b.HasIndex("ScheduleOwnerId"); + + b.ToTable("BrusherProfile"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Cares") + .HasColumnType("boolean"); + + b.Property("Cut") + .HasColumnType("boolean"); + + b.Property("Dressing") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("Length") + .HasColumnType("integer"); + + b.Property("Shampoo") + .HasColumnType("boolean"); + + b.Property("Tech") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("HairPrestation"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestationCollectionItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrestationId") + .HasColumnType("bigint"); + + b.Property("QueryId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PrestationId"); + + b.HasIndex("QueryId"); + + b.ToTable("HairPrestationCollectionItem"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairTaint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasColumnType("text"); + + b.Property("ColorId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ColorId"); + + b.ToTable("HairTaint"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairTaintInstance", b => + { + b.Property("TaintId") + .HasColumnType("bigint"); + + b.Property("PrestationId") + .HasColumnType("bigint"); + + b.HasKey("TaintId", "PrestationId"); + + b.HasIndex("PrestationId"); + + b.ToTable("HairTaintInstance"); + }); + + modelBuilder.Entity("Yavsc.Models.IT.Evolution.Feature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ShortName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Feature"); + }); + + modelBuilder.Entity("Yavsc.Models.IT.Fixing.Bug", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10240) + .HasColumnType("character varying(10240)"); + + b.Property("FeatureId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FeatureId"); + + b.ToTable("Bug"); + }); + + modelBuilder.Entity("Yavsc.Models.Identity.DeviceDeclaration", b => + { + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("DeclarationDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("LOCALTIMESTAMP"); + + b.Property("DeviceOwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LatestActivityUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DeviceId"); + + b.HasIndex("DeviceOwnerId"); + + b.ToTable("DeviceDeclaration"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.DeclarationFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeclarationId") + .HasColumnType("bigint"); + + b.Property("MatchExcerpt") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DeclarationId"); + + b.HasIndex("PatternId"); + + b.ToTable("DeclarationFlag"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.ModerationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("DeclarationId") + .HasColumnType("bigint"); + + b.Property("ModeratorId") + .HasColumnType("text"); + + b.Property("ScoreDelta") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeclarationId"); + + b.HasIndex("ModeratorId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ModerationLogs", t => + { + t.HasCheckConstraint("CK_ModerationLog_Immutable", "1=1"); + }); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.RegexAlertPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("RegexAlertPatterns"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.TrustDeclaration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DeclarantTokenId") + .HasColumnType("uuid"); + + b.Property("ScoreDelta") + .HasColumnType("integer"); + + b.Property("Sentiment") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrustTokenId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("SubmittedAt"); + + b.HasIndex("TrustTokenId"); + + b.ToTable("TrustDeclarations"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.TrustToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TrustScore") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("TrustTokens"); + }); + + modelBuilder.Entity("Yavsc.Models.Market.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Depth") + .HasColumnType("numeric"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("numeric"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("Public") + .HasColumnType("boolean"); + + b.Property("Weight") + .HasColumnType("numeric"); + + b.Property("Width") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Yavsc.Models.Market.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContextId") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Public") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ContextId"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Yavsc.Models.Messaging.Announce", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("For") + .HasColumnType("smallint"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Sender") + .HasColumnType("text"); + + b.Property("Topic") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Announce"); + }); + + modelBuilder.Entity("Yavsc.Models.Messaging.DismissClicked", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("NotificationId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "NotificationId"); + + b.HasIndex("NotificationId"); + + b.ToTable("DismissClicked"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Instrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Instrument"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.InstrumentRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("InstrumentId", "OwnerId"); + + b.HasIndex("OwnerId"); + + b.ToTable("InstrumentRating"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.MusicalPreference", b => + { + b.Property("OwnerProfileId") + .HasColumnType("text"); + + b.Property("DjSettingsUserId") + .HasColumnType("text"); + + b.Property("MusicLoverSettingsUserId") + .HasColumnType("text"); + + b.Property("Rate") + .HasColumnType("integer"); + + b.Property("TendencyId") + .HasColumnType("bigint"); + + b.HasKey("OwnerProfileId"); + + b.HasIndex("DjSettingsUserId"); + + b.HasIndex("MusicLoverSettingsUserId"); + + b.HasIndex("TendencyId"); + + b.ToTable("MusicalPreference"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.MusicalTendency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("MusicalTendency"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.DjSettings", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SoundCloudId") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("DjSettings"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.Instrumentation", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("InstrumentId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("Instrumentation"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("MusicLoverSettings"); + }); + + modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b => + { + b.Property("CreationToken") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderReference") + .HasColumnType("text"); + + b.Property("PaypalPayerId") + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("CreationToken"); + + b.HasIndex("ExecutorId"); + + b.ToTable("PayPalPayment"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Public") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("Circle"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.CircleMember", b => + { + b.Property("MemberId") + .HasColumnType("text"); + + b.Property("CircleId") + .HasColumnType("bigint"); + + b.HasKey("MemberId", "CircleId"); + + b.HasIndex("CircleId"); + + b.ToTable("CircleMembers"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Contact", b => + { + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("AddressId") + .HasColumnType("bigint"); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("OwnerId", "UserId"); + + b.HasIndex("AddressId"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("Contact"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.HyperLink", b => + { + b.Property("HRef") + .HasColumnType("text"); + + b.Property("Method") + .HasColumnType("text"); + + b.Property("BrusherProfileUserId") + .HasColumnType("text"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("PayPalPaymentCreationToken") + .HasColumnType("text"); + + b.Property("Rel") + .HasColumnType("text"); + + b.HasKey("HRef", "Method"); + + b.HasIndex("BrusherProfileUserId"); + + b.HasIndex("PayPalPaymentCreationToken"); + + b.ToTable("HyperLink"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.PostalAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Street1") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PostalAddress"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Yavsc.Models.ResourceUsageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiCalls") + .HasColumnType("numeric"); + + b.Property("BandwidthMb") + .HasColumnType("numeric"); + + b.Property("CpuSeconds") + .HasColumnType("numeric"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasDefaultValue("EUR"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedCompensation") + .HasColumnType("numeric"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageMb") + .HasColumnType("numeric"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAtUtc"); + + b.ToTable("ResourceUsageRecords"); + }); + + modelBuilder.Entity("Yavsc.Models.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Rate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SiteSkills"); + }); + + modelBuilder.Entity("Yavsc.Models.Streaming.LiveFlow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DifferedFileName") + .HasColumnType("text"); + + b.Property("MediaType") + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Pitch") + .HasColumnType("text"); + + b.Property("SequenceNumber") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("LiveFlow"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Activity", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Hidden") + .HasColumnType("boolean"); + + b.Property("Moderated") + .HasColumnType("boolean"); + + b.Property("ModeratorGroupName") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentCode") + .HasColumnType("text"); + + b.Property("Photo") + .HasColumnType("text"); + + b.Property("Rate") + .HasColumnType("integer"); + + b.Property("SettingsClassName") + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Code"); + + b.HasIndex("ParentCode"); + + b.ToTable("Activities"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.CoWorking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FormationSettingsUserId") + .HasColumnType("text"); + + b.Property("PerformerId") + .HasColumnType("text"); + + b.Property("WorkingForId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FormationSettingsUserId"); + + b.HasIndex("PerformerId"); + + b.HasIndex("WorkingForId"); + + b.ToTable("CoWorking"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.CommandForm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionName") + .HasColumnType("text"); + + b.Property("ActivityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityCode"); + + b.ToTable("CommandForm"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Country", b => + { + b.Property("Code") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Code"); + + b.ToTable("Countries"); + + b.HasData( + new + { + Code = "fr", + DisplayName = "France" + }, + new + { + Code = "en", + DisplayName = "England" + }, + new + { + Code = "pt", + DisplayName = "Portugal" + }); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DomaineActiviteCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Langue") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Nom") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("DomaineActiviteCode", "Langue", "Nom") + .IsUnique(); + + b.ToTable("DictionnaireMetier"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RegularExpression") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.ToTable("PerformerCodeInputValidations"); + + b.HasData( + new + { + Id = 1L, + CountryCode = "fr", + ErrorMessage = "Le code FR doit contenir entre 9 et 14 chiffres.", + RegularExpression = "^[0-9]{9,14}$" + }, + new + { + Id = 2L, + CountryCode = "en", + ErrorMessage = "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques.", + RegularExpression = "^[A-Za-z0-9]{8,14}$" + }, + new + { + Id = 3L, + CountryCode = "pt", + ErrorMessage = "Le code PT doit contenir exactement 9 chiffres.", + RegularExpression = "^[0-9]{9}$" + }); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b => + { + b.Property("PerformerId") + .HasColumnType("text"); + + b.Property("AcceptNotifications") + .HasColumnType("boolean"); + + b.Property("AcceptPublicContact") + .HasColumnType("boolean"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ExerciseCountryCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxDailyCost") + .HasColumnType("integer"); + + b.Property("MinDailyCost") + .HasColumnType("integer"); + + b.Property("OrganizationAddressId") + .HasColumnType("bigint"); + + b.Property("Rate") + .HasColumnType("integer"); + + b.Property("SIREN") + .IsRequired() + .HasColumnType("text"); + + b.Property("UseGeoLocalizationToReduceDistanceWithClients") + .HasColumnType("boolean"); + + b.Property("WebSite") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("PerformerId"); + + b.HasIndex("OrganizationAddressId"); + + b.ToTable("Performers"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Profiles.FormationSettings", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("FormationSettings"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.TermeMetier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateSoumission") + .HasColumnType("timestamp with time zone"); + + b.Property("DateValidation") + .HasColumnType("timestamp with time zone"); + + b.Property("Definition") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DictionnaireMetierId") + .HasColumnType("bigint"); + + b.Property("Langue") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Mot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProposeParId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("StatutValidation") + .HasColumnType("integer"); + + b.Property("ValideParId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("ProposeParId"); + + b.HasIndex("ValideParId"); + + b.HasIndex("DictionnaireMetierId", "Langue", "Mot") + .IsUnique(); + + b.ToTable("TermeMetier"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.UserActivity", b => + { + b.Property("DoesCode") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("DoesCode", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserActivities"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.Calendar.Period", b => + { + b.Property("Start") + .HasColumnType("timestamp with time zone"); + + b.Property("End") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Start", "End"); + + b.ToTable("Period"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.EMailing.MailingTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .HasMaxLength(65536) + .HasColumnType("character varying(65536)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplyToAddress") + .HasColumnType("text"); + + b.Property("ToSend") + .HasColumnType("integer"); + + b.Property("Topic") + .HasColumnType("text"); + + b.Property("UserCreated") + .HasColumnType("text"); + + b.Property("UserModified") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MailingTemplate"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.ProjectBuildConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("ProjectBuildConfiguration"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.SourceCode.GitRepositoryReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Branch") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("GitRepositoryReference"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b => + { + b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand"); + + b.Property("AdditionalInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationId") + .HasColumnType("bigint"); + + b.Property("PrestationId") + .HasColumnType("bigint"); + + b.Property("SelectedProfileUserId") + .HasColumnType("text"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrestationId"); + + b.HasIndex("SelectedProfileUserId"); + + b.HasDiscriminator().HasValue("HairCutQuery"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b => + { + b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand"); + + b.Property("EventDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationId") + .HasColumnType("bigint"); + + b.HasIndex("LocationId"); + + b.ToTable("NominativeServiceCommands", t => + { + t.Property("EventDate") + .HasColumnName("HairMultiCutQuery_EventDate"); + + t.Property("LocationId") + .HasColumnName("HairMultiCutQuery_LocationId"); + }); + + b.HasDiscriminator().HasValue("HairMultiCutQuery"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b => + { + b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand"); + + b.Property("EventDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationId") + .HasColumnType("bigint"); + + b.Property("LocationType") + .HasColumnType("integer"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasIndex("LocationId"); + + b.ToTable("NominativeServiceCommands", t => + { + t.Property("EventDate") + .HasColumnName("RdvQuery_EventDate"); + + t.Property("LocationId") + .HasColumnName("RdvQuery_LocationId"); + }); + + b.HasDiscriminator().HasValue("RdvQuery"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b => + { + b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand"); + + b.Property("GitId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("text"); + + b.HasIndex("GitId"); + + b.HasDiscriminator().HasValue("Project"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", null) + .WithMany("UserClaims") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany() + .HasForeignKey("ApiResourceId1"); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceProperty", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", null) + .WithMany("Properties") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany() + .HasForeignKey("ApiResourceId1"); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceScope", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", null) + .WithMany("Scopes") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany() + .HasForeignKey("ApiResourceId1"); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceSecret", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", null) + .WithMany("Secrets") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany() + .HasForeignKey("ApiResourceId1"); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeClaim", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiScope", null) + .WithMany("UserClaims") + .HasForeignKey("ScopeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiScope", "Scope") + .WithMany() + .HasForeignKey("ScopeId1"); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeProperty", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiScope", null) + .WithMany("Properties") + .HasForeignKey("ScopeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.ApiScope", "Scope") + .WithMany() + .HasForeignKey("ScopeId1"); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientClaim", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany("Claims") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientCorsOrigin", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) + .WithMany("AllowedCorsOrigins") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId1"); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany("AllowedGrantTypes") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientIdPRestriction", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) + .WithMany("IdentityProviderRestrictions") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId1"); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) + .WithMany("PostLogoutRedirectUris") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId1"); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientProperty", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) + .WithMany("Properties") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId1"); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany("RedirectUris") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany("AllowedScopes") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientSecret", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) + .WithMany("ClientSecrets") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId1"); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResourceClaim", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.IdentityResource", "IdentityResource") + .WithMany("UserClaims") + .HasForeignKey("IdentityResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("IdentityResource"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResourceProperty", b => + { + b.HasOne("IdentityServer8.EntityFramework.Entities.IdentityResource", "IdentityResource") + .WithMany("Properties") + .HasForeignKey("IdentityResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("IdentityResource"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Yavsc.Models.Access.Ban", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "TargetUser") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.BlackListed", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany("BlackList") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.CircleAuthorizationToBlogPost", b => + { + b.HasOne("Yavsc.Models.Blog.BlogPost", "Target") + .WithMany("ACL") + .HasForeignKey("BlogPostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Relationship.Circle", "Allowed") + .WithMany() + .HasForeignKey("CircleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Allowed"); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Yavsc.Models.Access.CircleAuthorizationToFile", b => + { + b.HasOne("Yavsc.Models.Relationship.Circle", "Allowed") + .WithMany() + .HasForeignKey("CircleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Allowed"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.AccountBalance", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithOne("AccountBalance") + .HasForeignKey("Yavsc.Models.AccountBalance", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.ApplicationUser", b => + { + b.HasOne("Yavsc.Models.Relationship.Location", "PostalAddress") + .WithMany() + .HasForeignKey("PostalAddressId"); + + b.Navigation("PostalAddress"); + }); + + modelBuilder.Entity("Yavsc.Models.BalanceImpact", b => + { + b.HasOne("Yavsc.Models.AccountBalance", "Balance") + .WithMany() + .HasForeignKey("BalanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Balance"); + }); + + modelBuilder.Entity("Yavsc.Models.Bank.BankIdentity", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "User") + .WithMany("BankInfo") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.CommandLine", b => + { + b.HasOne("Yavsc.Models.Billing.Estimate", null) + .WithMany("Bill") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Billing.EstimateTemplate", null) + .WithMany("Bill") + .HasForeignKey("EstimateTemplateId"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Client") + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Query") + .WithMany() + .HasForeignKey("CommandId"); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Owner"); + + b.Navigation("Query"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b => + { + b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate") + .WithMany() + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Blog.UploadedFile", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b => + { + b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command") + .WithMany() + .HasForeignKey("CommandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Command"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "Context") + .WithMany() + .HasForeignKey("ActivityCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "Client") + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Billing.Estimate", null) + .WithMany() + .HasForeignKey("InvoiceEstimateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization") + .WithMany() + .HasForeignKey("PaymentId"); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile") + .WithMany() + .HasForeignKey("PerformerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Context"); + + b.Navigation("PerformerProfile"); + + b.Navigation("Regularization"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b => + { + b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command") + .WithMany() + .HasForeignKey("CommandId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Blog.UploadedFile", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Command"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => + { + b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate") + .WithMany("Signatures") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("Signer"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b => + { + b.HasOne("Yavsc.Models.Blog.UploadedFile", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogTag", b => + { + b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") + .WithMany("Tags") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Relationship.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.Comment", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Author") + .WithMany("BlogComments") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Yavsc.Models.Blog.Comment", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId"); + + b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") + .WithMany("Comments") + .HasForeignKey("ReceiverId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Parent"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => + { + b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") + .WithOne("Publication") + .HasForeignKey("Yavsc.Models.BlogSpotPublication", "PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Calendar.ScheduledEvent", b => + { + b.HasOne("Yavsc.Models.Calendar.Schedule", null) + .WithMany("Events") + .HasForeignKey("ScheduleOwnerId"); + + b.HasOne("Yavsc.Server.Models.Calendar.Period", "Period") + .WithMany() + .HasForeignKey("PeriodStart", "PeriodEnd"); + + b.Navigation("Period"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatConnection", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany("Connections") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatRoom", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany("Rooms") + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatRoomAccess", b => + { + b.HasOne("Yavsc.Models.Chat.ChatRoom", "Room") + .WithMany("Moderation") + .HasForeignKey("ChannelName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "User") + .WithMany("RoomAccess") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.BrusherProfile", b => + { + b.HasOne("Yavsc.Models.Calendar.Schedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleOwnerId"); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "BaseProfile") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BaseProfile"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestationCollectionItem", b => + { + b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation") + .WithMany() + .HasForeignKey("PrestationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Haircut.HairMultiCutQuery", "Query") + .WithMany("Prestations") + .HasForeignKey("QueryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prestation"); + + b.Navigation("Query"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairTaint", b => + { + b.HasOne("Yavsc.Models.Drawing.Color", "Color") + .WithMany() + .HasForeignKey("ColorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Color"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairTaintInstance", b => + { + b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation") + .WithMany("Taints") + .HasForeignKey("PrestationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Haircut.HairTaint", "Taint") + .WithMany() + .HasForeignKey("TaintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prestation"); + + b.Navigation("Taint"); + }); + + modelBuilder.Entity("Yavsc.Models.IT.Fixing.Bug", b => + { + b.HasOne("Yavsc.Models.IT.Evolution.Feature", "False") + .WithMany() + .HasForeignKey("FeatureId"); + + b.Navigation("False"); + }); + + modelBuilder.Entity("Yavsc.Models.Identity.DeviceDeclaration", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "DeviceOwner") + .WithMany("DeviceDeclaration") + .HasForeignKey("DeviceOwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceOwner"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.DeclarationFlag", b => + { + b.HasOne("Yavsc.Models.Kyc.TrustDeclaration", "Declaration") + .WithMany("Flags") + .HasForeignKey("DeclarationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Kyc.RegexAlertPattern", "Pattern") + .WithMany() + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Declaration"); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.ModerationLog", b => + { + b.HasOne("Yavsc.Models.Kyc.TrustDeclaration", "Declaration") + .WithMany() + .HasForeignKey("DeclarationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Declaration"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.TrustDeclaration", b => + { + b.HasOne("Yavsc.Models.Kyc.TrustToken", "Subject") + .WithMany("Declarations") + .HasForeignKey("TrustTokenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Yavsc.Models.Market.Service", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "Context") + .WithMany("Services") + .HasForeignKey("ContextId"); + + b.Navigation("Context"); + }); + + modelBuilder.Entity("Yavsc.Models.Messaging.Announce", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Messaging.DismissClicked", b => + { + b.HasOne("Yavsc.Abstract.Models.Messaging.Notification", "Notified") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notified"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.InstrumentRating", b => + { + b.HasOne("Yavsc.Models.Musical.Instrument", "Instrument") + .WithMany() + .HasForeignKey("InstrumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "Profile") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instrument"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.MusicalPreference", b => + { + b.HasOne("Yavsc.Models.Musical.Profiles.DjSettings", null) + .WithMany("SoundColor") + .HasForeignKey("DjSettingsUserId"); + + b.HasOne("Yavsc.Models.Musical.Profiles.MusicLoverSettings", null) + .WithMany("SoundColor") + .HasForeignKey("MusicLoverSettingsUserId"); + + b.HasOne("Yavsc.Models.Musical.MusicalTendency", "MusicalTendency") + .WithMany() + .HasForeignKey("TendencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MusicalTendency"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.Instrumentation", b => + { + b.HasOne("Yavsc.Models.Musical.Instrument", "Tool") + .WithMany() + .HasForeignKey("InstrumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tool"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany("Circles") + .HasForeignKey("ApplicationUserId"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.CircleMember", b => + { + b.HasOne("Yavsc.Models.Relationship.Circle", "Circle") + .WithMany("Members") + .HasForeignKey("CircleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "Member") + .WithMany("Membership") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Circle"); + + b.Navigation("Member"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Contact", b => + { + b.HasOne("Yavsc.Models.Relationship.PostalAddress", "PostalAddress") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", null) + .WithMany("Book") + .HasForeignKey("ApplicationUserId"); + + b.Navigation("PostalAddress"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.HyperLink", b => + { + b.HasOne("Yavsc.Models.Haircut.BrusherProfile", null) + .WithMany("Links") + .HasForeignKey("BrusherProfileUserId"); + + b.HasOne("Yavsc.Models.Payment.PayPalPayment", null) + .WithMany("Links") + .HasForeignKey("PayPalPaymentCreationToken"); + }); + + modelBuilder.Entity("Yavsc.Models.Streaming.LiveFlow", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Activity", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentCode"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.CoWorking", b => + { + b.HasOne("Yavsc.Models.Workflow.Profiles.FormationSettings", null) + .WithMany("CoWorking") + .HasForeignKey("FormationSettingsUserId"); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "Performer") + .WithMany() + .HasForeignKey("PerformerId"); + + b.HasOne("Yavsc.Models.ApplicationUser", "WorkingFor") + .WithMany() + .HasForeignKey("WorkingForId"); + + b.Navigation("Performer"); + + b.Navigation("WorkingFor"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.CommandForm", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "Context") + .WithMany("Forms") + .HasForeignKey("ActivityCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Context"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "DomaineActivite") + .WithMany() + .HasForeignKey("DomaineActiviteCode") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DomaineActivite"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b => + { + b.HasOne("Yavsc.Models.Workflow.Country", "Country") + .WithMany() + .HasForeignKey("CountryCode") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b => + { + b.HasOne("Yavsc.Models.Relationship.Location", "OrganizationAddress") + .WithMany() + .HasForeignKey("OrganizationAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "Performer") + .WithMany() + .HasForeignKey("PerformerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationAddress"); + + b.Navigation("Performer"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.TermeMetier", b => + { + b.HasOne("Yavsc.Models.Workflow.DictionnaireMetier", "DictionnaireMetier") + .WithMany("Termes") + .HasForeignKey("DictionnaireMetierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.ApplicationUser", "ProposePar") + .WithMany() + .HasForeignKey("ProposeParId"); + + b.HasOne("Yavsc.Models.ApplicationUser", "ValidePar") + .WithMany() + .HasForeignKey("ValideParId"); + + b.Navigation("DictionnaireMetier"); + + b.Navigation("ProposePar"); + + b.Navigation("ValidePar"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.UserActivity", b => + { + b.HasOne("Yavsc.Models.Workflow.Activity", "Does") + .WithMany() + .HasForeignKey("DoesCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "User") + .WithMany("Activity") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Does"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.ProjectBuildConfiguration", b => + { + b.HasOne("Yavsc.Server.Models.IT.Project", "TargetProject") + .WithMany("Configurations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetProject"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.SourceCode.GitRepositoryReference", b => + { + b.HasOne("Yavsc.Models.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b => + { + b.HasOne("Yavsc.Models.Relationship.Location", "Location") + .WithMany() + .HasForeignKey("LocationId"); + + b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation") + .WithMany() + .HasForeignKey("PrestationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yavsc.Models.Haircut.BrusherProfile", "SelectedProfile") + .WithMany() + .HasForeignKey("SelectedProfileUserId"); + + b.Navigation("Location"); + + b.Navigation("Prestation"); + + b.Navigation("SelectedProfile"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b => + { + b.HasOne("Yavsc.Models.Relationship.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b => + { + b.HasOne("Yavsc.Models.Relationship.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b => + { + b.HasOne("Yavsc.Server.Models.IT.SourceCode.GitRepositoryReference", "Repository") + .WithMany() + .HasForeignKey("GitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Repository"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b => + { + b.Navigation("Properties"); + + b.Navigation("Scopes"); + + b.Navigation("Secrets"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScope", b => + { + b.Navigation("Properties"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.Client", b => + { + b.Navigation("AllowedCorsOrigins"); + + b.Navigation("AllowedGrantTypes"); + + b.Navigation("AllowedScopes"); + + b.Navigation("Claims"); + + b.Navigation("ClientSecrets"); + + b.Navigation("IdentityProviderRestrictions"); + + b.Navigation("PostLogoutRedirectUris"); + + b.Navigation("Properties"); + + b.Navigation("RedirectUris"); + }); + + modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResource", b => + { + b.Navigation("Properties"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("Yavsc.Models.ApplicationUser", b => + { + b.Navigation("AccountBalance"); + + b.Navigation("BankInfo"); + + b.Navigation("BlackList"); + + b.Navigation("BlogComments"); + + b.Navigation("Book"); + + b.Navigation("Circles"); + + b.Navigation("Connections"); + + b.Navigation("DeviceDeclaration"); + + b.Navigation("Membership"); + + b.Navigation("Posts"); + + b.Navigation("RoomAccess"); + + b.Navigation("Rooms"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b => + { + b.Navigation("Bill"); + + b.Navigation("Signatures"); + }); + + modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b => + { + b.Navigation("Bill"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b => + { + b.Navigation("ACL"); + + b.Navigation("Comments"); + + b.Navigation("Publication") + .IsRequired(); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Yavsc.Models.Blog.Comment", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("Yavsc.Models.Chat.ChatRoom", b => + { + b.Navigation("Moderation"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.BrusherProfile", b => + { + b.Navigation("Links"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestation", b => + { + b.Navigation("Taints"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.TrustDeclaration", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Yavsc.Models.Kyc.TrustToken", b => + { + b.Navigation("Declarations"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.DjSettings", b => + { + b.Navigation("SoundColor"); + }); + + modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b => + { + b.Navigation("SoundColor"); + }); + + modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b => + { + b.Navigation("Links"); + }); + + modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Activity", b => + { + b.Navigation("Children"); + + b.Navigation("Forms"); + + b.Navigation("Services"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b => + { + b.Navigation("Termes"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Yavsc.Models.Workflow.Profiles.FormationSettings", b => + { + b.Navigation("CoWorking"); + }); + + modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b => + { + b.Navigation("Prestations"); + }); + + modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b => + { + b.Navigation("Configurations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.cs b/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.cs new file mode 100644 index 000000000..4ec4a423b --- /dev/null +++ b/src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Yavsc.Migrations +{ + /// + public partial class SignedEstimateInvoicing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InvoiceClientId", + table: "NominativeServiceCommands", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceCurrency", + table: "NominativeServiceCommands", + type: "character varying(3)", + maxLength: 3, + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceEstimateId", + table: "NominativeServiceCommands", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceLinesJson", + table: "NominativeServiceCommands", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoicePerformerId", + table: "NominativeServiceCommands", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoicedAtUtc", + table: "NominativeServiceCommands", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "PaymentRevision", + table: "NominativeServiceCommands", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "InvoicePayments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CommandId = table.Column(type: "bigint", nullable: false), + Source = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + ReferenceScope = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + Reference = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + ReceivedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RecordedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RecordedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoicePayments", x => x.Id); + table.ForeignKey( + name: "FK_InvoicePayments_NominativeServiceCommands_CommandId", + column: x => x.CommandId, + principalTable: "NominativeServiceCommands", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_NominativeServiceCommands_InvoiceEstimateId", + table: "NominativeServiceCommands", + column: "InvoiceEstimateId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InvoicePayments_CommandId", + table: "InvoicePayments", + column: "CommandId"); + + migrationBuilder.CreateIndex( + name: "IX_InvoicePayments_Source_ReferenceScope_Reference", + table: "InvoicePayments", + columns: new[] { "Source", "ReferenceScope", "Reference" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId", + table: "NominativeServiceCommands", + column: "InvoiceEstimateId", + principalTable: "Estimates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId", + table: "NominativeServiceCommands"); + + migrationBuilder.DropTable( + name: "InvoicePayments"); + + migrationBuilder.DropIndex( + name: "IX_NominativeServiceCommands_InvoiceEstimateId", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoiceClientId", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoiceCurrency", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoiceEstimateId", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoiceLinesJson", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoicePerformerId", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "InvoicedAtUtc", + table: "NominativeServiceCommands"); + + migrationBuilder.DropColumn( + name: "PaymentRevision", + table: "NominativeServiceCommands"); + } + } +} diff --git a/src/Yavsc.Org/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Yavsc.Org/Migrations/ApplicationDbContextModelSnapshot.cs index a5388d225..ed657a1e6 100644 --- a/src/Yavsc.Org/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Yavsc.Org/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1442,6 +1442,61 @@ namespace Yavsc.Migrations b.ToTable("ExceptionsSIREN"); }); + modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CommandId") + .HasColumnType("bigint"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReferenceScope") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CommandId"); + + b.HasIndex("Source", "ReferenceScope", "Reference") + .IsUnique(); + + b.ToTable("InvoicePayments"); + }); + modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => { b.Property("Id") @@ -1484,9 +1539,34 @@ namespace Yavsc.Migrations .HasMaxLength(34) .HasColumnType("character varying(34)"); + b.Property("InvoiceClientId") + .HasColumnType("text"); + + b.Property("InvoiceCurrency") + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("InvoiceEstimateId") + .HasColumnType("bigint"); + + b.Property("InvoiceLinesJson") + .HasColumnType("text"); + + b.Property("InvoicePerformerId") + .HasColumnType("text"); + + b.Property("InvoicedAtUtc") + .HasColumnType("timestamp with time zone"); + b.Property("PaymentId") .HasColumnType("text"); + b.Property("PaymentRevision") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")); + b.Property("PerformerId") .IsRequired() .HasColumnType("text"); @@ -1514,6 +1594,9 @@ namespace Yavsc.Migrations b.HasIndex("ClientId"); + b.HasIndex("InvoiceEstimateId") + .IsUnique(); + b.HasIndex("PaymentId"); b.HasIndex("PerformerId"); @@ -3953,6 +4036,17 @@ namespace Yavsc.Migrations b.Navigation("File"); }); + modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b => + { + b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command") + .WithMany() + .HasForeignKey("CommandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Command"); + }); + modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => { b.HasOne("Yavsc.Models.Workflow.Activity", "Context") @@ -3967,6 +4061,11 @@ namespace Yavsc.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Yavsc.Models.Billing.Estimate", null) + .WithMany() + .HasForeignKey("InvoiceEstimateId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization") .WithMany() .HasForeignKey("PaymentId"); diff --git a/src/Yavsc.Server/Helpers/BillingHelpers.cs b/src/Yavsc.Server/Helpers/BillingHelpers.cs index ddfe481b3..e2d943616 100644 --- a/src/Yavsc.Server/Helpers/BillingHelpers.cs +++ b/src/Yavsc.Server/Helpers/BillingHelpers.cs @@ -18,6 +18,12 @@ namespace Yavsc.Helpers string total = query.GetBillItems().Addition().ToString("C", CultureInfo.CurrentUICulture); string bill = string.Join("\n", query.GetBillItems().Select(l=> $"{l.Name} {l.Description} : {l.UnitaryCost} € " + ((l.Count != 1) ? "*"+l.Count.ToString() : ""))) + $"\n\nTotal: {total}"; + if (query.PaymentSummary is { } payment) + { + bill += $"\nPaid: {payment.AmountPaid.ToString("C", CultureInfo.CurrentUICulture)}" + + $"\nRemaining: {payment.AmountRemaining.ToString("C", CultureInfo.CurrentUICulture)}" + + $"\nStatus: {payment.Status}"; + } return bill; } diff --git a/src/Yavsc.Server/Helpers/PayPalHelpers.cs b/src/Yavsc.Server/Helpers/PayPalHelpers.cs index 51902c784..4b2bbbad0 100644 --- a/src/Yavsc.Server/Helpers/PayPalHelpers.cs +++ b/src/Yavsc.Server/Helpers/PayPalHelpers.cs @@ -10,6 +10,7 @@ using Yavsc.ViewModels.PayPal; using Yavsc.Models; using Yavsc.Models.Payment; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace Yavsc.Helpers { @@ -72,7 +73,7 @@ namespace Yavsc.Helpers public static SetExpressCheckoutResponseType CreatePayment(this HttpRequest request, string controllerName, NominativeServiceCommand query, string intent = "sale", ILogger logger = null) { var items = query.GetBillItems(); - var total = items.Addition().ToString("F2"); + var total = items.Addition().ToString("F2", CultureInfo.InvariantCulture); var coreq = new SetExpressCheckoutReq {}; var urls = request.GetPaymentUrls(controllerName, query.Id.ToString()); @@ -89,7 +90,7 @@ namespace Yavsc.Helpers }, PaymentDetailsItem = new List ( items.Select(i => new PaymentDetailsItemType { - Amount = new BasicAmountType { currencyID = CurrencyCodeType.EUR, value = i.UnitaryCost.ToString("F2") }, + Amount = new BasicAmountType { currencyID = CurrencyCodeType.EUR, value = i.UnitaryCost.ToString("F2", CultureInfo.InvariantCulture) }, Name = i.Name, Quantity = i.Count, Description = i.Description diff --git a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs index 23adb981e..98e23c239 100644 --- a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs +++ b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs @@ -117,7 +117,8 @@ namespace Yavsc.Helpers ((db, id) => { var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id); - query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId); + if (!query.InvoiceEstimateId.HasValue) + query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId); return query; })); diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index e3a3b6dca..1d2c4c3ac 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -61,6 +61,22 @@ namespace Yavsc.Models builder.Entity() .Property(command => command.AmountPaid) .HasDefaultValue(0m); + builder.Entity() + .Property(command => command.PaymentRevision) + .HasDefaultValue(Guid.Empty); + builder.Entity() + .HasOne().WithMany() + .HasForeignKey(command => command.InvoiceEstimateId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasIndex(command => command.InvoiceEstimateId).IsUnique(); + builder.Entity() + .HasIndex(payment => new { payment.Source, payment.ReferenceScope, payment.Reference }) + .IsUnique(); + builder.Entity() + .HasOne(payment => payment.Command).WithMany() + .HasForeignKey(payment => payment.CommandId) + .OnDelete(DeleteBehavior.Restrict); builder.Entity(entity => { @@ -358,6 +374,7 @@ namespace Yavsc.Models public DbSet TagsDomain { get; set; } public DbSet EstimateTemplates { get; set; } + public DbSet InvoicePayments { get; set; } public DbSet Contact { get; set; } diff --git a/src/Yavsc.Server/Models/Billing/InvoicePayment.cs b/src/Yavsc.Server/Models/Billing/InvoicePayment.cs new file mode 100644 index 000000000..4b1e04987 --- /dev/null +++ b/src/Yavsc.Server/Models/Billing/InvoicePayment.cs @@ -0,0 +1,28 @@ +#nullable enable + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Yavsc.Models.Billing; + +public sealed class InvoicePayment +{ + public long Id { get; set; } + public long CommandId { get; set; } + [ForeignKey(nameof(CommandId)), JsonIgnore, Newtonsoft.Json.JsonIgnore] + public NominativeServiceCommand Command { get; set; } = null!; + [MaxLength(16)] + public string Source { get; set; } = ""; + [MaxLength(450)] + public string ReferenceScope { get; set; } = ""; + [MaxLength(128)] + public string Reference { get; set; } = ""; + public decimal Amount { get; set; } + [MaxLength(3)] + public string Currency { get; set; } = ""; + public DateTimeOffset ReceivedAtUtc { get; set; } + public DateTimeOffset RecordedAtUtc { get; set; } + [MaxLength(450)] + public string RecordedBy { get; set; } = ""; +} diff --git a/src/Yavsc.Server/Models/Billing/NominativeServiceCommand.cs b/src/Yavsc.Server/Models/Billing/NominativeServiceCommand.cs index 6375dcc0e..9dd59afbb 100644 --- a/src/Yavsc.Server/Models/Billing/NominativeServiceCommand.cs +++ b/src/Yavsc.Server/Models/Billing/NominativeServiceCommand.cs @@ -88,6 +88,50 @@ namespace Yavsc.Models.Billing public decimal AmountPaid { get; private set; } + public long? InvoiceEstimateId { get; private set; } + public DateTimeOffset? InvoicedAtUtc { get; private set; } + [MaxLength(3)] + public string? InvoiceCurrency { get; private set; } + public string? InvoiceClientId { get; private set; } + public string? InvoicePerformerId { get; private set; } + [JsonIgnore, System.Text.Json.Serialization.JsonIgnore] + public string? InvoiceLinesJson { get; private set; } + [ConcurrencyCheck] + public Guid PaymentRevision { get; private set; } + + public void EstablishInvoice(long estimateId, string currency, IReadOnlyList lines) + { + ArgumentNullException.ThrowIfNull(lines); + if (InvoiceEstimateId.HasValue || AmountDue.HasValue || AmountPaid != 0m) + throw new InvalidOperationException("A balance or invoice already exists for this command."); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(estimateId); + if (currency != "EUR" || lines.Count == 0 + || lines.Any(line => line.Count <= 0 || decimal.Round(line.UnitaryCost, 2) != line.UnitaryCost)) + throw new ArgumentException("An invoice requires EUR lines with positive quantities."); + var due = lines.Sum(line => checked(line.Count * line.UnitaryCost)); + SetPaymentAmounts(due, 0m); + InvoiceEstimateId = estimateId; + InvoiceCurrency = currency; + InvoiceClientId = ClientId; + InvoicePerformerId = PerformerId; + var now = DateTimeOffset.UtcNow; + InvoicedAtUtc = new DateTimeOffset(now.UtcTicks - now.UtcTicks % 10, TimeSpan.Zero); + InvoiceLinesJson = System.Text.Json.JsonSerializer.Serialize(lines); + } + + public List GetInvoiceBillItems() + { + if (InvoiceLinesJson is null || InvoiceCurrency is null) + throw new InvalidOperationException("The invoice has no frozen lines."); + var lines = System.Text.Json.JsonSerializer.Deserialize>(InvoiceLinesJson) + ?? throw new InvalidOperationException("The invoice lines are invalid."); + return lines.Select(line => (IBillItem)new CommandLine + { + Name = line.Name, Description = line.Description, + Count = line.Count, UnitaryCost = line.UnitaryCost, Currency = InvoiceCurrency + }).ToList(); + } + [NotMapped] public PaymentSummary? PaymentSummary => AmountDue.HasValue ? new PaymentSummary(AmountDue.Value, AmountPaid) @@ -95,9 +139,12 @@ namespace Yavsc.Models.Billing public void SetPaymentAmounts(decimal amountDue, decimal amountPaid) { + if (InvoiceEstimateId.HasValue && AmountDue != amountDue) + throw new InvalidOperationException("An established invoice total cannot be changed."); var summary = new PaymentSummary(amountDue, amountPaid); AmountDue = summary.AmountDue; AmountPaid = summary.AmountPaid; + PaymentRevision = Guid.NewGuid(); } public bool GetIsAcquitted() diff --git a/src/Yavsc.Server/Models/HairCut/HairCutQuery.cs b/src/Yavsc.Server/Models/HairCut/HairCutQuery.cs index 680cf6956..a1dccb7f6 100644 --- a/src/Yavsc.Server/Models/HairCut/HairCutQuery.cs +++ b/src/Yavsc.Server/Models/HairCut/HairCutQuery.cs @@ -60,6 +60,7 @@ namespace Yavsc.Models.Haircut public override List GetBillItems() { + if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems(); string longhairsuffix = " (cheveux longs)"; string halflonghairsuffix = " (cheveux mi-longs)"; string shorthairsuffix = " (cheveux courts)"; diff --git a/src/Yavsc.Server/Models/HairCut/HairMultiCutQuery.cs b/src/Yavsc.Server/Models/HairCut/HairMultiCutQuery.cs index beb7da9ac..4705fbdfe 100644 --- a/src/Yavsc.Server/Models/HairCut/HairMultiCutQuery.cs +++ b/src/Yavsc.Server/Models/HairCut/HairMultiCutQuery.cs @@ -39,6 +39,7 @@ namespace Yavsc.Models.Haircut public override List GetBillItems() { + if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems(); throw new NotImplementedException(); } } diff --git a/src/Yavsc.Server/Models/IT/Project.cs b/src/Yavsc.Server/Models/IT/Project.cs index 1fce99de3..4d2f8e47a 100644 --- a/src/Yavsc.Server/Models/IT/Project.cs +++ b/src/Yavsc.Server/Models/IT/Project.cs @@ -44,6 +44,7 @@ namespace Yavsc.Server.Models.IT } public override List GetBillItems() { + if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems(); return bill; } @@ -62,4 +63,3 @@ namespace Yavsc.Server.Models.IT } } - diff --git a/src/Yavsc.Server/Models/Workflow/RdvQuery.cs b/src/Yavsc.Server/Models/Workflow/RdvQuery.cs index cd56f3676..1a325ed43 100644 --- a/src/Yavsc.Server/Models/Workflow/RdvQuery.cs +++ b/src/Yavsc.Server/Models/Workflow/RdvQuery.cs @@ -71,6 +71,7 @@ namespace Yavsc.Models.Workflow public override List GetBillItems() { + if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems(); throw new NotImplementedException(); } } diff --git a/src/Yavsc.Server/Services/InvoicingService.cs b/src/Yavsc.Server/Services/InvoicingService.cs new file mode 100644 index 000000000..bebf81809 --- /dev/null +++ b/src/Yavsc.Server/Services/InvoicingService.cs @@ -0,0 +1,191 @@ +#nullable enable + +using Microsoft.EntityFrameworkCore; +using Yavsc.Billing; +using Yavsc.Models; +using Yavsc.Models.Billing; + +namespace Yavsc.Services; + +public interface IInvoicingService +{ + Task GenerateAsync(long estimateId, string actorId, bool administrator, CancellationToken ct); + Task GetAsync(long commandId, string actorId, bool administrator, CancellationToken ct); + Task RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, string actorId, bool administrator, CancellationToken ct); + Task VerifyPayPalAsync(long commandId, string transactionId, string actorId, bool administrator, CancellationToken ct); +} + +public sealed class InvoiceOperationException(int statusCode, string message) : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} + +public sealed class InvoicingService(ApplicationDbContext db, IPayPalTransactionVerifier payPal) : IInvoicingService +{ + public async Task GenerateAsync(long estimateId, string actorId, bool administrator, CancellationToken ct) + { + var estimate = await db.Estimates.Include(e => e.Bill).Include(e => e.Signatures) + .SingleOrDefaultAsync(e => e.Id == estimateId, ct) + ?? throw new InvoiceOperationException(404, "Estimate not found."); + RequirePerformer(actorId, estimate.OwnerId, administrator); + if (!estimate.CommandId.HasValue) + throw new InvoiceOperationException(409, "The estimate must be attached to a command."); + + var command = await db.NominativeServiceCommands.SingleOrDefaultAsync(q => q.Id == estimate.CommandId, ct) + ?? throw new InvoiceOperationException(409, "The estimate's command no longer exists."); + if (command.InvoiceEstimateId == estimateId) + { + RequirePerformer(actorId, command.InvoicePerformerId!, administrator); + return await ResponseAsync(command, ct); + } + if (command.InvoiceEstimateId.HasValue || command.AmountDue.HasValue || command.AmountPaid != 0m) + throw new InvoiceOperationException(409, "This command already has an invoice or a balance requiring reconciliation."); + if (command.ClientId != estimate.ClientId || command.PerformerId != estimate.OwnerId) + throw new InvoiceOperationException(409, "The estimate and command must have the same client and performer."); + if (estimate.ProviderValidationDate == default || estimate.ClientValidationDate == default + || estimate.SignaturePro?.SignerId != estimate.OwnerId || estimate.SignatureClient?.SignerId != estimate.ClientId + || estimate.SignaturePro.Strokes.Length == 0 || estimate.SignatureClient.Strokes.Length == 0) + throw new InvoiceOperationException(409, "Both parties must have signed and validated the estimate."); + if (estimate.Bill.Count == 0 || estimate.Bill.Any(line => line.Currency != "EUR" || line.Count <= 0 + || decimal.Round(line.UnitaryCost, 2) != line.UnitaryCost)) + throw new InvoiceOperationException(409, "The estimate requires EUR lines with positive quantities and prices in whole cents."); + + var lines = estimate.Bill.Select(line => new InvoiceLineResponse( + line.Name, line.Description, line.Count, line.UnitaryCost)).ToList(); + decimal due; + try + { + due = lines.Sum(line => checked(line.Count * line.UnitaryCost)); + } + catch (OverflowException) + { + throw new InvoiceOperationException(409, "The estimate total is too large."); + } + if (due < 0m) + throw new InvoiceOperationException(409, "The invoice total cannot be negative."); + + command.EstablishInvoice(estimateId, "EUR", lines); + await SaveAsync(actorId, ct); + return await ResponseAsync(command, ct); + } + + public async Task GetAsync(long commandId, string actorId, bool administrator, CancellationToken ct) + { + var command = await LoadAsync(commandId, actorId, administrator, ct); + return await ResponseAsync(command, ct); + } + + public async Task RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, + string actorId, bool administrator, CancellationToken ct) + { + var command = await LoadAsync(commandId, actorId, administrator, ct); + RequirePerformer(actorId, command.InvoicePerformerId!, administrator); + if (string.IsNullOrWhiteSpace(request.Reference) || request.Reference.Length > 128 + || request.Reference != request.Reference.Trim() || request.Amount <= 0m + || decimal.Round(request.Amount, 2) != request.Amount + || request.Currency != command.InvoiceCurrency || request.ReceivedAtUtc == default + || request.ReceivedAtUtc > DateTimeOffset.UtcNow) + throw new InvoiceOperationException(400, "A positive receipt, invoice currency, reference and non-future receipt date are required."); + return await RecordAsync(command, "Offline", command.InvoicePerformerId!, request.Reference, request.Amount, + request.Currency, request.ReceivedAtUtc.ToUniversalTime(), actorId, ct); + } + + public async Task VerifyPayPalAsync(long commandId, string transactionId, + string actorId, bool administrator, CancellationToken ct) + { + var command = await LoadAsync(commandId, actorId, administrator, ct); + if (string.IsNullOrWhiteSpace(transactionId) || transactionId.Length > 128 || transactionId != transactionId.Trim()) + throw new InvoiceOperationException(400, "A PayPal transaction identifier is required."); + var receipt = await payPal.VerifyAsync(transactionId, command.GetInvoiceId(), command.InvoiceCurrency!, ct); + return await RecordAsync(command, "PayPal", "", receipt.TransactionId, receipt.Amount, + receipt.Currency, receipt.ReceivedAtUtc, actorId, ct); + } + + private async Task RecordAsync(NominativeServiceCommand command, string source, string scope, + string reference, decimal amount, string currency, DateTimeOffset receivedAtUtc, string actorId, CancellationToken ct) + { + // PostgreSQL persists microseconds; normalize before comparing a replay. + receivedAtUtc = new DateTimeOffset(receivedAtUtc.UtcTicks - receivedAtUtc.UtcTicks % 10, TimeSpan.Zero); + var existing = await db.InvoicePayments.SingleOrDefaultAsync( + payment => payment.Source == source && payment.ReferenceScope == scope && payment.Reference == reference, ct); + if (existing is not null) + { + if (existing.CommandId != command.Id || existing.Amount != amount || existing.Currency != currency + || existing.ReceivedAtUtc != receivedAtUtc) + throw new InvoiceOperationException(409, "The receipt reference is already used with different details."); + return await ResponseAsync(command, ct); + } + + var payments = await db.InvoicePayments.Where(payment => payment.CommandId == command.Id).ToListAsync(ct); + var total = payments.Sum(payment => payment.Amount); + if (command.AmountPaid != total) + throw new InvoiceOperationException(409, "The recorded balance requires reconciliation with the receipt history."); + try + { + command.SetPaymentAmounts(command.AmountDue!.Value, checked(total + amount)); + } + catch (OverflowException) + { + throw new InvoiceOperationException(400, "The cumulative payment amount is too large."); + } + db.InvoicePayments.Add(new InvoicePayment + { + CommandId = command.Id, Source = source, ReferenceScope = scope, Reference = reference, + Amount = amount, Currency = currency, ReceivedAtUtc = receivedAtUtc, + RecordedAtUtc = DateTimeOffset.UtcNow, RecordedBy = actorId + }); + await SaveAsync(actorId, ct); + return await ResponseAsync(command, ct); + } + + private async Task LoadAsync(long commandId, string actorId, bool administrator, CancellationToken ct) + { + var command = await db.NominativeServiceCommands.SingleOrDefaultAsync(q => q.Id == commandId, ct) + ?? throw new InvoiceOperationException(404, "Invoice not found."); + if (!command.InvoiceEstimateId.HasValue) + throw new InvoiceOperationException(404, "Invoice not found."); + if (!administrator && actorId != command.InvoiceClientId && actorId != command.InvoicePerformerId) + throw new InvoiceOperationException(403, "Only the invoice parties may access it."); + return command; + } + + private async Task ResponseAsync(NominativeServiceCommand command, CancellationToken ct) + { + var payments = await db.InvoicePayments.AsNoTracking().Where(payment => payment.CommandId == command.Id) + .OrderBy(payment => payment.Id).ToListAsync(ct); + var summary = new PaymentSummary(command.AmountDue + ?? throw new InvalidOperationException("An established invoice must have a balance."), + payments.Sum(payment => payment.Amount)); + var lines = command.GetInvoiceBillItems().Select(line => + new InvoiceLineResponse(line.Name, line.Description, line.Count, line.UnitaryCost)).ToList(); + return new InvoiceResponse(command.Id, command.InvoiceEstimateId!.Value, command.GetInvoiceId(), + command.InvoiceClientId!, command.InvoicePerformerId!, command.InvoicedAtUtc!.Value, command.InvoiceCurrency!, + summary.AmountDue, summary.AmountPaid, summary.AmountRemaining, summary.Status.ToString(), summary.IsAcquitted, + lines, payments.Select(payment => new InvoicePaymentResponse( + payment.Id, payment.Source, payment.Reference, payment.Amount, payment.Currency, + payment.ReceivedAtUtc, payment.RecordedAtUtc, payment.RecordedBy)).ToList()); + } + + private async Task SaveAsync(string actorId, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(actorId, ct); + } + catch (DbUpdateConcurrencyException) + { + throw new InvoiceOperationException(409, "The invoice changed concurrently. Retry the same request."); + } + catch (DbUpdateException exception) when (exception.InnerException is + System.Data.Common.DbException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + throw new InvoiceOperationException(409, "The invoice or receipt reference already exists. Retry the same request."); + } + } + + private static void RequirePerformer(string actorId, string performerId, bool administrator) + { + if (!administrator && actorId != performerId) + throw new InvoiceOperationException(403, "Only the performer may perform this operation."); + } +} diff --git a/src/Yavsc.Server/Services/PayPalTransactionVerifier.cs b/src/Yavsc.Server/Services/PayPalTransactionVerifier.cs new file mode 100644 index 000000000..931e7332c --- /dev/null +++ b/src/Yavsc.Server/Services/PayPalTransactionVerifier.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System.Globalization; +using Microsoft.Extensions.Options; + +namespace Yavsc.Services; + +public sealed record VerifiedPayPalReceipt(string TransactionId, decimal Amount, string Currency, DateTimeOffset ReceivedAtUtc); + +public interface IPayPalTransactionVerifier +{ + Task VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct); +} + +public sealed class PayPalTransactionVerifier(HttpClient http, IOptions options) : IPayPalTransactionVerifier +{ + public async Task VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct) + { + var settings = options.Value; + var accounts = settings.Accounts?.Where(account => account.ApiUsername == settings.MerchantAccountUserName).ToArray(); + if (accounts is not { Length: 1 }) + throw new InvoiceOperationException(503, "PayPal transaction verification requires one matching merchant account."); + var credentials = accounts[0]; + if (string.IsNullOrWhiteSpace(credentials.ApiUsername) + || string.IsNullOrWhiteSpace(credentials.ApiPassword) || string.IsNullOrWhiteSpace(credentials.Signature) + || string.IsNullOrWhiteSpace(settings.MerchantAccountId) || settings.Mode is not ("production" or "sandbox")) + throw new InvoiceOperationException(503, "PayPal transaction verification is not configured."); + var endpoint = settings.Mode == "production" + ? "https://api-3t.paypal.com/nvp" : "https://api-3t.sandbox.paypal.com/nvp"; + using var content = new FormUrlEncodedContent(new Dictionary + { + ["METHOD"] = "GetTransactionDetails", ["VERSION"] = "124.0", + ["USER"] = credentials.ApiUsername, ["PWD"] = credentials.ApiPassword, + ["SIGNATURE"] = credentials.Signature, ["TRANSACTIONID"] = transactionId + }); + + string body; + try + { + using var response = await http.PostAsync(endpoint, content, ct); + if (!response.IsSuccessStatusCode) + throw new InvoiceOperationException(502, "PayPal transaction verification failed."); + body = await response.Content.ReadAsStringAsync(ct); + } + catch (HttpRequestException) + { + throw new InvoiceOperationException(502, "PayPal is unavailable. No receipt was recorded."); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new InvoiceOperationException(504, "PayPal verification timed out. No receipt was recorded."); + } + + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var pair in body.Split('&')) + { + var parts = pair.Split('=', 2); + if (parts.Length != 2 || !fields.TryAdd(Decode(parts[0]), Decode(parts[1]))) + throw new InvoiceOperationException(502, "PayPal returned an invalid verification response."); + } + string? Field(string name) => fields.GetValueOrDefault(name); + if (Field("ACK") is not ("Success" or "SuccessWithWarning")) + throw new InvoiceOperationException(502, "PayPal did not verify the transaction."); + if (Field("TRANSACTIONID") != transactionId || Field("INVNUM") != invoiceReference + || Field("RECEIVERID") != settings.MerchantAccountId || Field("CURRENCYCODE") != currency + || Field("PAYMENTSTATUS") != "Completed") + throw new InvoiceOperationException(409, "The PayPal receipt is not a completed payment for this invoice, currency and merchant."); + if (!decimal.TryParse(Field("AMT"), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var amount) + || amount <= 0m || decimal.Round(amount, 2) != amount + || !DateTimeOffset.TryParse(Field("ORDERTIME"), CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var receivedAt) || receivedAt == default || receivedAt > DateTimeOffset.UtcNow) + throw new InvoiceOperationException(502, "PayPal returned an invalid receipt amount or date."); + return new VerifiedPayPalReceipt(transactionId, amount, currency, receivedAt.ToUniversalTime()); + } + + private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " ")); +} diff --git a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs index 5765bd52a..68d14c57b 100644 --- a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs +++ b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs @@ -78,6 +78,7 @@ public sealed class ApiWebServerFixture : WebHostFixture builder.Services.Configure(_ => { }); builder.Services.Configure(settings => settings.Authority = "example.test"); builder.Services.AddTransient(); + builder.Services.AddInvoicing(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton>(sp => sp.GetRequiredService()); diff --git a/src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs b/src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs index ecdca0840..e5032fa33 100644 --- a/src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/HairCutQueryApiControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Yavsc.ApiControllers; +using Yavsc.Billing; using Yavsc.Api.Test.Fixtures; using Yavsc.Models; using Yavsc.Models.Haircut; @@ -140,6 +141,15 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture(); var command = await db.HairCutQueries.SingleAsync(TestContext.Current.CancellationToken); + var estimate = new Yavsc.Models.Billing.Estimate + { + CommandId = command.Id, ClientId = command.ClientId, OwnerId = command.PerformerId, + CommandType = "Brush", Title = "Invoice test", Description = "Invoice test", + Bill = [], AttachedFiles = [], AttachedGraphics = [] + }; + db.Estimates.Add(estimate); + await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken); + command.EstablishInvoice(estimate.Id, "EUR", [new InvoiceLineResponse("Service", "Service", 1, 100m)]); command.SetPaymentAmounts(100m, 30m); await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken); payload = Newtonsoft.Json.JsonConvert.DeserializeObject( @@ -166,6 +176,11 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture item.Count * item.UnitaryCost)); } } diff --git a/src/test/Yavsc.Api.Tests/InvoiceApiTests.cs b/src/test/Yavsc.Api.Tests/InvoiceApiTests.cs new file mode 100644 index 000000000..aecafe2d2 --- /dev/null +++ b/src/test/Yavsc.Api.Tests/InvoiceApiTests.cs @@ -0,0 +1,449 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Api.Test.Fixtures; +using Yavsc.Billing; +using Yavsc.Helpers; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Workflow; +using Yavsc.Services; +using Yavsc.Tests.Shared; + +namespace Yavsc.Api.Test; + +[Collection("Yavsc Api")] +public sealed class InvoiceApiTests(ApiWebServerFixture fixture) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly DateTimeOffset Received = new(2026, 1, 2, 12, 0, 0, TimeSpan.Zero); + + private HttpClient Client(string? subject = "alice") + { + var http = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }) { BaseAddress = new Uri(fixture.BaseAddress) }; + if (subject is not null) + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, "api")); + return http; + } + + private (long Estimate, long Command) Seed(bool providerSignature = true, bool clientSignature = true, bool reset = true) + { + if (reset) fixture.ResetAndSeedActivityGraph(); + using var scope = fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var command = new RdvQuery + { + ClientId = "bob", PerformerId = "alice", ActivityCode = "dev", + Consent = true, Reason = "Invoice test", Location = db.Locations.Single(), + EventDate = DateTime.UtcNow.AddDays(1), UserCreated = "alice", UserModified = "alice" + }; + db.Add(command); + db.SaveChanges(); + var estimate = new Estimate + { + CommandId = command.Id, ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv, + Title = "Signed estimate", Description = "Invoice test", + AttachedFiles = [], AttachedGraphics = [], + ProviderValidationDate = DateTime.UtcNow, ClientValidationDate = DateTime.UtcNow, + Bill = + [ + new CommandLine { Name = "Service", Description = "Service", Count = 2, UnitaryCost = 60m }, + new CommandLine { Name = "Discount", Description = "Discount", Count = 1, UnitaryCost = -20m } + ] + }; + db.Add(estimate); + db.SaveChanges(); + if (providerSignature) db.Signatures.Add(Signature(estimate.Id, SignatureType.Pro, "alice")); + if (clientSignature) db.Signatures.Add(Signature(estimate.Id, SignatureType.Client, "bob")); + db.SaveChanges(); + return (estimate.Id, command.Id); + } + + private static Signature Signature(long estimateId, SignatureType type, string signer) + => new() + { + EstimateId = estimateId, Type = type, SignerId = signer, CoordinateMax = 10_000, + CapturedAtUtc = DateTime.UtcNow, Strokes = [2, 0, 0, 100, 100] + }; + + private static RecordInvoicePaymentRequest Receipt(string reference, decimal amount = 30m) + => new() { Reference = reference, Amount = amount, Currency = "EUR", ReceivedAtUtc = Received }; + + private static async Task InvoiceAsync(HttpResponseMessage response, HttpStatusCode expected) + { + var body = await response.Content.ReadAsStringAsync(Ct); + Assert.True(response.StatusCode == expected, $"{response.StatusCode}: {body}"); + return (await response.Content.ReadFromJsonAsync(Ct))!; + } + + [Fact] + public async Task Signed_estimate_creates_a_frozen_unpaid_invoice_and_generation_is_idempotent() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + using var created = await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct); + var invoice = await InvoiceAsync(created, HttpStatusCode.Created); + Assert.EndsWith($"/api/v1/invoices/{commandId}", created.Headers.Location!.ToString()); + Assert.Equal(commandId, invoice.CommandId); + Assert.Equal(estimateId, invoice.EstimateId); + Assert.Equal("bob", invoice.ClientId); + Assert.Equal("alice", invoice.PerformerId); + Assert.Equal(100m, invoice.AmountDue); + Assert.Equal(0m, invoice.AmountPaid); + Assert.Equal(100m, invoice.AmountRemaining); + Assert.Equal("Unpaid", invoice.PaymentStatus); + Assert.False(invoice.IsAcquitted); + Assert.Empty(invoice.Payments); + using (var scope = fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var line = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId).Bill.First(); + line.UnitaryCost = 999m; + db.SaveChanges(); + var command = db.RdvQueries.Single(q => q.Id == commandId); + Assert.Equal(100m, command.GetBillItems().Sum(item => item.Count * item.UnitaryCost)); + } + var repeated = await InvoiceAsync( + await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + Assert.Equal(invoice.CreatedAtUtc, repeated.CreatedAtUtc); + Assert.Equal(100m, repeated.AmountDue); + Assert.Equal(invoice.Lines.ToArray(), repeated.Lines.ToArray()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Dates_without_both_persisted_signatures_cannot_generate_an_invoice(bool provider, bool client) + { + var (estimateId, commandId) = Seed(provider, client); + using var http = Client(); + var response = await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + using var scope = fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Null(db.RdvQueries.Single(q => q.Id == commandId).AmountDue); + } + + [Theory] + [InlineData("wrong-signer")] + [InlineData("missing-date")] + [InlineData("wrong-client")] + [InlineData("wrong-performer")] + [InlineData("standalone")] + [InlineData("negative-total")] + [InlineData("wrong-currency")] + [InlineData("invalid-quantity")] + [InlineData("fractional-cent")] + [InlineData("empty-signature")] + public async Task Inconsistent_estimates_are_rejected(string fault) + { + var (estimateId, _) = Seed(); + using (var scope = fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var estimate = db.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(e => e.Id == estimateId); + switch (fault) + { + case "wrong-signer": estimate.SignatureClient!.SignerId = "alice"; break; + case "missing-date": estimate.ClientValidationDate = default; break; + case "wrong-client": estimate.ClientId = "alice"; break; + case "wrong-performer": db.RdvQueries.Single().PerformerId = "bob"; break; + case "standalone": estimate.CommandId = null; break; + case "negative-total": estimate.Bill.ForEach(line => line.UnitaryCost = -1m); break; + case "wrong-currency": estimate.Bill[0].Currency = "USD"; break; + case "invalid-quantity": estimate.Bill[0].Count = 0; break; + case "fractional-cent": estimate.Bill[0].UnitaryCost = 0.001m; break; + case "empty-signature": estimate.SignatureClient!.Strokes = []; break; + } + db.SaveChanges(); + } + using var http = Client(); + Assert.Equal(HttpStatusCode.Conflict, + (await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct)).StatusCode); + } + + [Fact] + public async Task Receipts_accumulate_idempotently_until_the_invoice_is_fully_paid() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + var path = $"/api/v1/invoices/{commandId}/payments/offline"; + var partial = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("bank-1"), Ct), HttpStatusCode.OK); + Assert.Equal(30m, partial.AmountPaid); + Assert.Equal(70m, partial.AmountRemaining); + Assert.Equal("PartiallyPaid", partial.PaymentStatus); + Assert.False(partial.IsAcquitted); + Assert.Single(partial.Payments); + using (var partialScope = fixture.Services.CreateScope()) + { + var partialDb = partialScope.ServiceProvider.GetRequiredService(); + var partialCommand = partialDb.RdvQueries.Single(q => q.Id == commandId); + Assert.Contains("Status: PartiallyPaid", partialCommand.GetBillText()); + Assert.DoesNotContain("Status: Paid", partialCommand.GetBillText()); + } + var replay = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("bank-1"), Ct), HttpStatusCode.OK); + Assert.Equal(30m, replay.AmountPaid); + Assert.Single(replay.Payments); + Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync(path, Receipt("bank-1", 31m), Ct)).StatusCode); + var total = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("cash-2", 70m), Ct), HttpStatusCode.OK); + Assert.Equal(100m, total.AmountPaid); + Assert.Equal(0m, total.AmountRemaining); + Assert.True(total.IsAcquitted); + Assert.Equal("Paid", total.PaymentStatus); + Assert.Equal(2, total.Payments.Count); + using var client = Client("bob"); + var read = await InvoiceAsync(await client.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK); + Assert.Equal(100m, read.AmountPaid); + using var scope = fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var command = db.RdvQueries.Single(q => q.Id == commandId); + Assert.Equal(100m, command.AmountPaid); + Assert.True(command.GetIsAcquitted()); + } + + [Theory] + [InlineData("negative")] + [InlineData("zero")] + [InlineData("currency")] + [InlineData("date")] + [InlineData("future")] + [InlineData("fractional-cent")] + public async Task Invalid_receipts_do_not_change_the_invoice(string fault) + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + var request = fault switch + { + "negative" => Receipt("bad") with { Amount = -1m }, + "zero" => Receipt("bad") with { Amount = 0m }, + "currency" => Receipt("bad") with { Currency = "USD" }, + "date" => Receipt("bad") with { ReceivedAtUtc = default }, + "future" => Receipt("bad") with { ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(1) }, + _ => Receipt("bad") with { Amount = 0.001m } + }; + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/offline", request, Ct)).StatusCode); + var invoice = await InvoiceAsync(await http.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK); + Assert.Equal(0m, invoice.AmountPaid); + Assert.Empty(invoice.Payments); + } + + [Fact] + public async Task Only_performer_can_generate_or_attest_and_only_parties_can_read_or_verify() + { + var (estimateId, commandId) = Seed(); + using var provider = Client(); + using var client = Client("bob"); + using var outsider = Client("mallory"); + using var anonymous = Client(null); + var generate = $"/api/v1/invoices/from-estimate/{estimateId}"; + Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync(generate, null, Ct)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync(generate, null, Ct)).StatusCode); + await InvoiceAsync(await provider.PostAsync(generate, null, Ct), HttpStatusCode.Created); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await outsider.GetAsync($"/api/v1/invoices/{commandId}", Ct)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await outsider.PostAsJsonAsync( + $"/api/v1/invoices/{commandId}/payments/paypal", new { transactionId = "TX-1" }, Ct)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await provider.GetAsync("/api/v1/invoices/999999999", Ct)).StatusCode); + } + + [Fact] + public async Task Unconfigured_paypal_never_creates_a_receipt() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + var response = await http.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/paypal", new { transactionId = "TX-1" }, Ct); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + var invoice = await InvoiceAsync(await http.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK); + Assert.Empty(invoice.Payments); + Assert.Equal(0m, invoice.AmountPaid); + } + + [Fact] + public async Task Verified_paypal_receipts_combine_with_offline_payments_without_double_counting() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + await InvoiceAsync(await http.PostAsJsonAsync( + $"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK); + using (var scope = fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var service = new InvoicingService(db, new VerifiedPayPalStub()); + var invoice = await service.VerifyPayPalAsync(commandId, "TX-1", "bob", false, Ct); + Assert.True(invoice.IsAcquitted); + Assert.Equal(100m, invoice.AmountPaid); + Assert.Equal(2, invoice.Payments.Count); + } + using (var scope = fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var service = new InvoicingService(db, new VerifiedPayPalStub()); + var invoice = await service.VerifyPayPalAsync(commandId, "TX-1", "bob", false, Ct); + Assert.Equal(100m, invoice.AmountPaid); + Assert.Equal(2, invoice.Payments.Count); + } + } + + [Fact] + public async Task A_receipt_reference_cannot_be_reused_for_another_invoice() + { + var first = Seed(); + var second = Seed(reset: false); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{first.Estimate}", null, Ct), HttpStatusCode.Created); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{second.Estimate}", null, Ct), HttpStatusCode.Created); + await InvoiceAsync(await http.PostAsJsonAsync( + $"/api/v1/invoices/{first.Command}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync( + $"/api/v1/invoices/{second.Command}/payments/offline", Receipt("bank-1"), Ct)).StatusCode); + using (var scope = fixture.Services.CreateScope()) + { + var service = new InvoicingService(scope.ServiceProvider.GetRequiredService(), new VerifiedPayPalStub()); + await service.VerifyPayPalAsync(first.Command, "TX-1", "bob", false, Ct); + } + using (var scope = fixture.Services.CreateScope()) + { + var service = new InvoicingService(scope.ServiceProvider.GetRequiredService(), new VerifiedPayPalStub()); + var error = await Assert.ThrowsAsync( + () => service.VerifyPayPalAsync(second.Command, "TX-1", "bob", false, Ct)); + Assert.Equal(409, error.StatusCode); + } + } + + [Fact] + public async Task Concurrent_receipts_are_atomic_and_the_losing_request_can_be_retried() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + using var scope = fixture.Services.CreateScope(); + var pause = new SavePause(); + var options = new DbContextOptionsBuilder( + scope.ServiceProvider.GetRequiredService>()) + .AddInterceptors(pause).Options; + await using var stale = new ApplicationDbContext(options); + var staleService = new InvoicingService(stale, new VerifiedPayPalStub()); + var first = staleService.RecordOfflineAsync(commandId, Receipt("bank-1"), "alice", false, Ct); + try + { + await pause.Started.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + using var otherScope = fixture.Services.CreateScope(); + var otherService = new InvoicingService(otherScope.ServiceProvider.GetRequiredService(), new VerifiedPayPalStub()); + var other = await otherService.RecordOfflineAsync(commandId, Receipt("bank-2", 40m), "alice", false, Ct); + Assert.Equal(40m, other.AmountPaid); + } + finally + { + pause.Resume.TrySetResult(); + } + var conflict = await Assert.ThrowsAsync(() => first); + Assert.Equal(409, conflict.StatusCode); + using (var checkScope = fixture.Services.CreateScope()) + { + var db = checkScope.ServiceProvider.GetRequiredService(); + Assert.Single(db.InvoicePayments); + Assert.Equal(40m, db.RdvQueries.Single(q => q.Id == commandId).AmountPaid); + } + var retry = await InvoiceAsync(await http.PostAsJsonAsync( + $"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK); + Assert.Equal(70m, retry.AmountPaid); + Assert.Equal(2, retry.Payments.Count); + } + + [Fact] + public async Task Invoiced_commands_and_estimates_cannot_be_deleted() + { + var (estimateId, commandId) = Seed(); + using var provider = Client(); + using var client = Client("bob"); + await InvoiceAsync(await provider.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"/api/v1/billing/Rdv/{commandId}", Ct)).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await provider.DeleteAsync($"/api/v1/estimate/{estimateId}", Ct)).StatusCode); + } + + [Fact] + public async Task Legacy_document_generation_cannot_bypass_invoice_establishment() + { + var (_, commandId) = Seed(); + using var provider = Client(); + Assert.Equal(HttpStatusCode.Conflict, + (await provider.PostAsync($"/api/v1/bill/genpdf/Rdv/{commandId}", null, Ct)).StatusCode); + } + + [Fact] + public async Task Invoice_total_cannot_be_overwritten_and_overpayments_keep_the_actual_amount() + { + var (estimateId, commandId) = Seed(); + using var http = Client(); + await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created); + using (var scope = fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var command = db.RdvQueries.Single(q => q.Id == commandId); + Assert.Throws(() => command.SetPaymentAmounts(10m, 0m)); + Assert.Equal(100m, command.AmountDue); + } + var request = Receipt("bank-overpaid", 120m) with { ReceivedAtUtc = Received.AddTicks(1234567) }; + var invoice = await InvoiceAsync(await http.PostAsJsonAsync( + $"/api/v1/invoices/{commandId}/payments/offline", request, Ct), HttpStatusCode.OK); + Assert.Equal(120m, invoice.AmountPaid); + Assert.Equal(0m, invoice.AmountRemaining); + Assert.True(invoice.IsAcquitted); + var replay = await InvoiceAsync(await http.PostAsJsonAsync( + $"/api/v1/invoices/{commandId}/payments/offline", request, Ct), HttpStatusCode.OK); + Assert.Equal(120m, replay.AmountPaid); + Assert.Single(replay.Payments); + } + + [Fact] + public async Task Openapi_documents_the_invoice_routes() + { + using var admin = Client(); + admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", + TestTokenIssuer.Issue("alice", "api", extraClaims: + [new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminGroupName)])); + var response = await admin.GetAsync("/openapi/invoicing.json", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(Ct); + Assert.Contains("/api/v1/invoices/from-estimate/{estimateId}", json); + Assert.Contains("/api/v1/invoices/{commandId}/payments/paypal", json); + Assert.Contains("/api/v1/invoices/{commandId}/payments/offline", json); + } + + private sealed class SavePause : SaveChangesInterceptor + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Started.TrySetResult(); + await Resume.Task.WaitAsync(cancellationToken); + return result; + } + } + + private sealed class VerifiedPayPalStub : IPayPalTransactionVerifier + { + public Task VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct) + { + Assert.StartsWith("RdvQuery/", invoiceReference); + Assert.Equal("EUR", currency); + return Task.FromResult(new VerifiedPayPalReceipt(transactionId, 70m, currency, Received)); + } + } +} diff --git a/src/test/Yavsc.Server.Tests/PayPalTransactionVerifierTests.cs b/src/test/Yavsc.Server.Tests/PayPalTransactionVerifierTests.cs new file mode 100644 index 000000000..cc29691d4 --- /dev/null +++ b/src/test/Yavsc.Server.Tests/PayPalTransactionVerifierTests.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Net; +using Microsoft.Extensions.Options; +using Yavsc.Services; + +namespace Yavsc.Server.Tests; + +public sealed class PayPalTransactionVerifierTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static Dictionary Completed() => new() + { + ["ACK"] = "Success", ["TRANSACTIONID"] = "TX-1", ["INVNUM"] = "RdvQuery/42", + ["RECEIVERID"] = "merchant-id", ["CURRENCYCODE"] = "EUR", ["PAYMENTSTATUS"] = "Completed", + ["AMT"] = "30.25", ["ORDERTIME"] = "2026-01-02T12:00:00Z" + }; + + private static PayPalSettings Settings(string mode = "sandbox") => new() + { + Mode = mode, MerchantAccountUserName = "test-api-user", MerchantAccountId = "merchant-id", + Accounts = + [ + new PayPalSettings.ClassicPayPalAccountApiCredential + { + ApiUsername = "test-api-user", ApiPassword = "test-password", Signature = "test-signature" + } + ] + }; + + private static string Encode(Dictionary fields) + => string.Join("&", fields.Select(field => $"{Uri.EscapeDataString(field.Key)}={Uri.EscapeDataString(field.Value)}")); + + [Theory] + [InlineData("sandbox", "api-3t.sandbox.paypal.com")] + [InlineData("production", "api-3t.paypal.com")] + public async Task Completed_receipt_is_verified_with_server_credentials_and_invariant_amount(string mode, string host) + { + using var handler = new Handler(Encode(Completed())); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings(mode))); + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var receipt = await verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct); + Assert.Equal(30.25m, receipt.Amount); + Assert.Equal("EUR", receipt.Currency); + Assert.Equal("TX-1", receipt.TransactionId); + Assert.Equal(TimeSpan.Zero, receipt.ReceivedAtUtc.Offset); + Assert.Equal(host, handler.Uri!.Host); + Assert.Equal("https", handler.Uri.Scheme); + Assert.Contains("METHOD=GetTransactionDetails", handler.Body); + Assert.Contains("TRANSACTIONID=TX-1", handler.Body); + Assert.Contains("USER=test-api-user", handler.Body); + Assert.Contains("PWD=test-password", handler.Body); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [Theory] + [InlineData("PAYMENTSTATUS", "Pending", 409)] + [InlineData("PAYMENTSTATUS", "Refunded", 409)] + [InlineData("PAYMENTSTATUS", "Reversed", 409)] + [InlineData("TRANSACTIONID", "TX-2", 409)] + [InlineData("INVNUM", "RdvQuery/43", 409)] + [InlineData("RECEIVERID", "another-merchant", 409)] + [InlineData("CURRENCYCODE", "USD", 409)] + [InlineData("ACK", "Failure", 502)] + [InlineData("AMT", "-1", 502)] + [InlineData("AMT", "0", 502)] + [InlineData("AMT", "30,25", 502)] + [InlineData("AMT", "0.001", 502)] + [InlineData("ORDERTIME", "not-a-date", 502)] + [InlineData("ORDERTIME", "2999-01-01T12:00:00Z", 502)] + public async Task Unverified_or_mismatched_transactions_are_rejected(string field, string value, int status) + { + var fields = Completed(); + fields[field] = value; + using var handler = new Handler(Encode(fields)); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings())); + + var exception = await Assert.ThrowsAsync( + () => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct)); + Assert.Equal(status, exception.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("ACK=Success&ACK=Success")] + [InlineData("ACK=Success&malformed")] + public async Task Malformed_responses_do_not_confirm_receipts(string body) + { + using var handler = new Handler(body); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings())); + var exception = await Assert.ThrowsAsync( + () => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct)); + Assert.Equal(502, exception.StatusCode); + } + + [Fact] + public async Task Missing_configuration_fails_before_any_network_call() + { + using var handler = new Handler(Encode(Completed())); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(new PayPalSettings())); + var exception = await Assert.ThrowsAsync( + () => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct)); + Assert.Equal(503, exception.StatusCode); + Assert.Null(handler.Uri); + } + + [Fact] + public async Task Http_failure_does_not_confirm_a_receipt() + { + using var handler = new Handler("unavailable", HttpStatusCode.ServiceUnavailable); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings())); + var exception = await Assert.ThrowsAsync( + () => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct)); + Assert.Equal(502, exception.StatusCode); + } + + [Fact] + public async Task Caller_cancellation_is_not_converted_into_a_payment_failure() + { + using var handler = new Handler(Encode(Completed())); + using var http = new HttpClient(handler); + var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings())); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + () => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", cancellation.Token)); + } + + private sealed class Handler(string body, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public Uri? Uri { get; private set; } + public string? Body { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + Uri = request.RequestUri; + Body = await request.Content!.ReadAsStringAsync(ct); + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + } +}