feat(invoicing): connect signed estimates and verified payment receipts

Freeze invoice totals and parties, record offline receipts and verify completed PayPal transactions. Protect idempotence and concurrent balance updates; add migration, API client, documentation and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 20:29:08 +01:00
commit 2dcbcd35b3
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
37 changed files with 6686 additions and 12 deletions

View file

@ -5,6 +5,13 @@
### Added ### 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 : * [Yavsc.Abstract, Yavsc.Server] Modele de solde des commandes facturables :
montants dus et payes persistants, reste a payer et etats non paye, montants dus et payes persistants, reste a payer et etats non paye,
partiellement paye ou acquitte. L'acquittement depend des montants, partiellement paye ou acquitte. L'acquittement depend des montants,
@ -20,6 +27,11 @@
### Fixed ### 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 ## [1.0.8-rc27] - unstable
### Added ### Added

View file

@ -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 : La migration ajoute les montants sans reconstituer de paiements historiques :
les anciennes commandes ont un dû inconnu et un payé nul. Un rapprochement 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. 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é La génération depuis le devis et l'alimentation du payé sont désormais
par des transactions confirmées restent à brancher aux étapes suivantes. branchées par le service `InvoicingService` (étapes 2 et 4 ci-dessous).
L'appel `SetPaymentAmounts` remplace un total cumulé ; il n'enregistre pas L'appel `SetPaymentAmounts` remplace un total cumulé ; il n'enregistre pas
une transaction et ne doit pas être utilisé pour additionner un retour PayPal une transaction et ne doit pas être utilisé pour additionner un retour PayPal
sans contrôle d'idempotence. sans contrôle d'idempotence.
@ -142,9 +142,33 @@ Objectifs couverts :
### Étape 2 — brancher la génération de facture sur le devis signé ### Étape 2 — brancher la génération de facture sur le devis signé
- vérifier que le devis est bien doublement validé, Implémentée par `POST /api/v1/invoices/from-estimate/{estimateId}` :
- déclencher la facture depuis le contexte du devis,
- rattacher la facture au bon client / prestataire. - 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 ### É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 ### Étape 4 — enregistrer les paiements sans surinterpréter le statut
- enregister le paiement effectué, Implémentée par deux sources d'encaissement, sans débit déclenché par ces routes :
- 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. | 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é ### Étape 5 — finaliser l'état acquitté
@ -164,8 +250,32 @@ Objectifs couverts :
- si `totalPaye >= totalDu`, alors passer la facture en état acquittée, - si `totalPaye >= totalDu`, alors passer la facture en état acquittée,
- envoyer les notifications / documents associés au cas échéant. - 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 ## 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 : 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, - devis signé par les deux parties → génération d'une facture non acquittée,

View file

@ -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<VerifyInvoicePayPalRequest>(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<ArgumentOutOfRangeException>(() => client.GenerateAsync(0, ct));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.GetAsync(-1, ct));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.RecordOfflineAsync(42, null!, ct));
await Assert.ThrowsAsync<ArgumentException>(() => 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<T> CallAsync<T>(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<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -34,6 +34,7 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(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 frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl); var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl); var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
@ -104,6 +105,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(invoicesClient);
services.AddSingleton(frontClient); services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient); services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient); services.AddSingleton(administrationClient);

View file

@ -0,0 +1,38 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Billing;
/// <summary>A frozen invoice line copied from the signed estimate.</summary>
public sealed record InvoiceLineResponse(string Name, string Description, int Count, decimal UnitaryCost);
/// <summary>An invoice and its cumulative settlement balance.</summary>
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<InvoiceLineResponse> Lines, IReadOnlyList<InvoicePaymentResponse> Payments);
/// <summary>An immutable recorded receipt, not an instruction to debit the client.</summary>
public sealed record InvoicePaymentResponse(
long Id, string Source, string Reference, decimal Amount, string Currency,
DateTimeOffset ReceivedAtUtc, DateTimeOffset RecordedAtUtc, string RecordedBy);
/// <summary>A receipt attested by the invoice's performer.</summary>
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; }
}
/// <summary>A PayPal transaction identifier to verify on the server; no client amount is trusted.</summary>
public sealed record VerifyInvoicePayPalRequest
{
[Required, MaxLength(128)]
public required string TransactionId { get; init; }
}

View file

@ -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<string> _businessBaseAddress;
public InvoicesApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
}
public Task<InvoiceResponse> GenerateAsync(long estimateId, CancellationToken ct = default)
=> CallAsync(HttpMethod.Post, $"from-estimate/{PositiveId(estimateId)}", null, ct);
public Task<InvoiceResponse> GetAsync(long commandId, CancellationToken ct = default)
=> CallAsync(HttpMethod.Get, $"{PositiveId(commandId)}", null, ct);
public Task<InvoiceResponse> RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct = default)
=> CallAsync(HttpMethod.Post, $"{PositiveId(commandId)}/payments/offline",
request ?? throw new ArgumentNullException(nameof(request)), ct);
public Task<InvoiceResponse> 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<InvoiceResponse> CallAsync(HttpMethod method, string path, object? body, CancellationToken ct)
{
var baseAddress = _businessBaseAddress();
ArgumentException.ThrowIfNullOrWhiteSpace(baseAddress);
return _api.CallAsync<InvoiceResponse>(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;
}
}

View file

@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers
{ {
return new ChallengeResult(); 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"; Response.ContentType = "text/x-tex";
return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true }); return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true });
} }
@ -103,6 +105,10 @@ namespace Yavsc.ApiControllers
return this.NotFound(); return this.NotFound();
} }
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString()); 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 } ); return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
} }

View file

@ -193,6 +193,8 @@ namespace Yavsc.Controllers
return BadRequest(ModelState); 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.Estimates.Remove(estimate);
_context.SaveChanges(User.GetUserId()); _context.SaveChanges(User.GetUserId());

View file

@ -154,6 +154,9 @@ public class HairCutQueryApiController : Controller
return BadRequest(ModelState); 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.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId; existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent; existing.Consent = query.Consent;
@ -187,6 +190,8 @@ public class HairCutQueryApiController : Controller
return Forbid(); return Forbid();
} }
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query); _context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -170,6 +170,8 @@ public class HairMultiCutQueryApiController : Controller
return BadRequest(ModelState); 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); _context.RemoveRange(existing.Prestations);
existing.ActivityCode = query.ActivityCode; existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId; existing.PerformerId = query.PerformerId;
@ -203,6 +205,8 @@ public class HairMultiCutQueryApiController : Controller
return Forbid(); return Forbid();
} }
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RemoveRange(query.Prestations); _context.RemoveRange(query.Prestations);
_context.HairMultiCutQueries.Remove(query); _context.HairMultiCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -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<InvoicesController> logger) : ControllerBase
{
/// <summary>Creates a frozen invoice from an estimate signed by both parties, or returns its existing invoice.</summary>
[HttpPost("from-estimate/{estimateId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> Generate(long estimateId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GenerateAsync(estimateId, User.GetUserId(), IsAdmin, ct), created: true);
/// <summary>Returns the invoice, balance and receipt history to its parties.</summary>
[HttpGet("{commandId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public Task<ActionResult<InvoiceResponse>> Get(long commandId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GetAsync(commandId, User.GetUserId(), IsAdmin, ct));
/// <summary>Records an offline receipt attested by the performer, idempotently by reference.</summary>
[HttpPost("{commandId:long}/payments/offline")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> RecordOffline(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.RecordOfflineAsync(commandId, request, User.GetUserId(), IsAdmin, ct));
/// <summary>Verifies a completed PayPal transaction server-side before recording its receipt.</summary>
[HttpPost("{commandId:long}/payments/paypal")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public Task<ActionResult<InvoiceResponse>> 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<ActionResult<InvoiceResponse>> ExecuteAsync(Func<Task<InvoiceResponse>> 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);
}
}
}

View file

@ -162,6 +162,9 @@ public class RdvQueryApiController : Controller
return Forbid(); 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.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId; existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent; existing.Consent = query.Consent;
@ -224,6 +227,8 @@ public class RdvQueryApiController : Controller
return Forbid(); return Forbid();
} }
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RdvQueries.Remove(query); _context.RdvQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -88,9 +88,22 @@ namespace Yavsc.ApiControllers
return BadRequest(); 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).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.AmountDue).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.AmountPaid).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 try
{ {
@ -206,6 +219,8 @@ namespace Yavsc.ApiControllers
return NotFound(); return NotFound();
} }
if (hairCutQuery.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(hairCutQuery); _context.HairCutQueries.Remove(hairCutQuery);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();

View file

@ -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"
}

View file

@ -72,6 +72,7 @@ internal class Program
// the document-generation flow and BillingController. // the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site")); services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
services.AddPodcasts(builder.Configuration); services.AddPodcasts(builder.Configuration);
services.AddInvoicing(builder.Configuration);
// accepts any access token issued by identity server // accepts any access token issued by identity server
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")

View file

@ -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<IInvoicingService, InvoicingService>();
services.Configure<PayPalSettings>(configuration.GetSection("Authentication:PayPal"));
services.AddHttpClient<IPayPalTransactionVerifier, PayPalTransactionVerifier>(client =>
client.Timeout = TimeSpan.FromSeconds(30));
services.AddOpenApi("invoicing", options =>
options.ShouldInclude = description => description.GroupName == "invoicing");
return services;
}
}

View file

@ -147,6 +147,8 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (query.ClientId != uid) if (query.ClientId != uid)
return new ChallengeResult(); return new ChallengeResult();
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query); _context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return await Index(); return await Index();

View file

@ -158,6 +158,8 @@ namespace Yavsc.Controllers
public async Task<IActionResult> DeleteConfirmed(long id) public async Task<IActionResult> DeleteConfirmed(long id)
{ {
Project project = await _context.Project.SingleAsync(m => m.Id == 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); _context.Project.Remove(project);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return RedirectToAction("Index"); return RedirectToAction("Index");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class SignedEstimateInvoicing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "InvoiceClientId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceCurrency",
table: "NominativeServiceCommands",
type: "character varying(3)",
maxLength: 3,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "InvoiceEstimateId",
table: "NominativeServiceCommands",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceLinesJson",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoicePerformerId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "InvoicedAtUtc",
table: "NominativeServiceCommands",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CommandId = table.Column<long>(type: "bigint", nullable: false),
Source = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ReferenceScope = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
Reference = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
ReceivedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedBy = table.Column<string>(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);
}
/// <inheritdoc />
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");
}
}
}

View file

@ -1442,6 +1442,61 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN"); b.ToTable("ExceptionsSIREN");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<DateTimeOffset>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecordedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Reference")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ReferenceScope")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("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 => modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1484,9 +1539,34 @@ namespace Yavsc.Migrations
.HasMaxLength(34) .HasMaxLength(34)
.HasColumnType("character varying(34)"); .HasColumnType("character varying(34)");
b.Property<string>("InvoiceClientId")
.HasColumnType("text");
b.Property<string>("InvoiceCurrency")
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<long?>("InvoiceEstimateId")
.HasColumnType("bigint");
b.Property<string>("InvoiceLinesJson")
.HasColumnType("text");
b.Property<string>("InvoicePerformerId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("InvoicedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PaymentId") b.Property<string>("PaymentId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<Guid>("PaymentRevision")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000"));
b.Property<string>("PerformerId") b.Property<string>("PerformerId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@ -1514,6 +1594,9 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("InvoiceEstimateId")
.IsUnique();
b.HasIndex("PaymentId"); b.HasIndex("PaymentId");
b.HasIndex("PerformerId"); b.HasIndex("PerformerId");
@ -3953,6 +4036,17 @@ namespace Yavsc.Migrations
b.Navigation("File"); 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 => modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{ {
b.HasOne("Yavsc.Models.Workflow.Activity", "Context") b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3967,6 +4061,11 @@ namespace Yavsc.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Yavsc.Models.Billing.Estimate", null)
.WithMany()
.HasForeignKey("InvoiceEstimateId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization") b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany() .WithMany()
.HasForeignKey("PaymentId"); .HasForeignKey("PaymentId");

View file

@ -18,6 +18,12 @@ namespace Yavsc.Helpers
string total = query.GetBillItems().Addition().ToString("C", CultureInfo.CurrentUICulture); 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() : ""))) + string bill = string.Join("\n", query.GetBillItems().Select(l=> $"{l.Name} {l.Description} : {l.UnitaryCost} € " + ((l.Count != 1) ? "*"+l.Count.ToString() : ""))) +
$"\n\nTotal: {total}"; $"\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; return bill;
} }

View file

@ -10,6 +10,7 @@ using Yavsc.ViewModels.PayPal;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Payment; using Yavsc.Models.Payment;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization;
namespace Yavsc.Helpers 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) public static SetExpressCheckoutResponseType CreatePayment(this HttpRequest request, string controllerName, NominativeServiceCommand query, string intent = "sale", ILogger logger = null)
{ {
var items = query.GetBillItems(); var items = query.GetBillItems();
var total = items.Addition().ToString("F2"); var total = items.Addition().ToString("F2", CultureInfo.InvariantCulture);
var coreq = new SetExpressCheckoutReq {}; var coreq = new SetExpressCheckoutReq {};
var urls = request.GetPaymentUrls(controllerName, query.Id.ToString()); var urls = request.GetPaymentUrls(controllerName, query.Id.ToString());
@ -89,7 +90,7 @@ namespace Yavsc.Helpers
}, },
PaymentDetailsItem = new List <PaymentDetailsItemType> ( PaymentDetailsItem = new List <PaymentDetailsItemType> (
items.Select(i => new PaymentDetailsItemType { 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, Name = i.Name,
Quantity = i.Count, Quantity = i.Count,
Description = i.Description Description = i.Description

View file

@ -117,7 +117,8 @@ namespace Yavsc.Helpers
((db, id) => ((db, id) =>
{ {
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == 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; return query;
})); }));

View file

@ -61,6 +61,22 @@ namespace Yavsc.Models
builder.Entity<NominativeServiceCommand>() builder.Entity<NominativeServiceCommand>()
.Property(command => command.AmountPaid) .Property(command => command.AmountPaid)
.HasDefaultValue(0m); .HasDefaultValue(0m);
builder.Entity<NominativeServiceCommand>()
.Property(command => command.PaymentRevision)
.HasDefaultValue(Guid.Empty);
builder.Entity<NominativeServiceCommand>()
.HasOne<Estimate>().WithMany()
.HasForeignKey(command => command.InvoiceEstimateId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<NominativeServiceCommand>()
.HasIndex(command => command.InvoiceEstimateId).IsUnique();
builder.Entity<InvoicePayment>()
.HasIndex(payment => new { payment.Source, payment.ReferenceScope, payment.Reference })
.IsUnique();
builder.Entity<InvoicePayment>()
.HasOne(payment => payment.Command).WithMany()
.HasForeignKey(payment => payment.CommandId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<ResourceUsageRecord>(entity => builder.Entity<ResourceUsageRecord>(entity =>
{ {
@ -358,6 +374,7 @@ namespace Yavsc.Models
public DbSet<BlogTag> TagsDomain { get; set; } public DbSet<BlogTag> TagsDomain { get; set; }
public DbSet<EstimateTemplate> EstimateTemplates { get; set; } public DbSet<EstimateTemplate> EstimateTemplates { get; set; }
public DbSet<InvoicePayment> InvoicePayments { get; set; }
public DbSet<Contact> Contact { get; set; } public DbSet<Contact> Contact { get; set; }

View file

@ -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; } = "";
}

View file

@ -88,6 +88,50 @@ namespace Yavsc.Models.Billing
public decimal AmountPaid { get; private set; } 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<InvoiceLineResponse> 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<IBillItem> GetInvoiceBillItems()
{
if (InvoiceLinesJson is null || InvoiceCurrency is null)
throw new InvalidOperationException("The invoice has no frozen lines.");
var lines = System.Text.Json.JsonSerializer.Deserialize<List<InvoiceLineResponse>>(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] [NotMapped]
public PaymentSummary? PaymentSummary => AmountDue.HasValue public PaymentSummary? PaymentSummary => AmountDue.HasValue
? new PaymentSummary(AmountDue.Value, AmountPaid) ? new PaymentSummary(AmountDue.Value, AmountPaid)
@ -95,9 +139,12 @@ namespace Yavsc.Models.Billing
public void SetPaymentAmounts(decimal amountDue, decimal amountPaid) 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); var summary = new PaymentSummary(amountDue, amountPaid);
AmountDue = summary.AmountDue; AmountDue = summary.AmountDue;
AmountPaid = summary.AmountPaid; AmountPaid = summary.AmountPaid;
PaymentRevision = Guid.NewGuid();
} }
public bool GetIsAcquitted() public bool GetIsAcquitted()

View file

@ -60,6 +60,7 @@ namespace Yavsc.Models.Haircut
public override List<IBillItem> GetBillItems() public override List<IBillItem> GetBillItems()
{ {
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
string longhairsuffix = " (cheveux longs)"; string longhairsuffix = " (cheveux longs)";
string halflonghairsuffix = " (cheveux mi-longs)"; string halflonghairsuffix = " (cheveux mi-longs)";
string shorthairsuffix = " (cheveux courts)"; string shorthairsuffix = " (cheveux courts)";

View file

@ -39,6 +39,7 @@ namespace Yavsc.Models.Haircut
public override List<IBillItem> GetBillItems() public override List<IBillItem> GetBillItems()
{ {
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }

View file

@ -44,6 +44,7 @@ namespace Yavsc.Server.Models.IT
} }
public override List<IBillItem> GetBillItems() public override List<IBillItem> GetBillItems()
{ {
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
return bill; return bill;
} }
@ -62,4 +63,3 @@ namespace Yavsc.Server.Models.IT
} }
} }

View file

@ -71,6 +71,7 @@ namespace Yavsc.Models.Workflow
public override List<IBillItem> GetBillItems() public override List<IBillItem> GetBillItems()
{ {
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }

View file

@ -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<InvoiceResponse> GenerateAsync(long estimateId, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> GetAsync(long commandId, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> 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<InvoiceResponse> 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<InvoiceResponse> 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<InvoiceResponse> 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<InvoiceResponse> 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<InvoiceResponse> 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<NominativeServiceCommand> 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<InvoiceResponse> 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.");
}
}

View file

@ -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<VerifiedPayPalReceipt> VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct);
}
public sealed class PayPalTransactionVerifier(HttpClient http, IOptions<PayPalSettings> options) : IPayPalTransactionVerifier
{
public async Task<VerifiedPayPalReceipt> 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<string, string>
{
["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<string, string>(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("+", " "));
}

View file

@ -78,6 +78,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.Configure<GoogleAuthSettings>(_ => { }); builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test"); builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>(); builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddInvoicing(builder.Configuration);
builder.Services.AddSingleton<RecordingMessageDelivery>(); builder.Services.AddSingleton<RecordingMessageDelivery>();
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>()); builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>()); builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Yavsc.ApiControllers; using Yavsc.ApiControllers;
using Yavsc.Billing;
using Yavsc.Api.Test.Fixtures; using Yavsc.Api.Test.Fixtures;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Haircut; using Yavsc.Models.Haircut;
@ -140,6 +141,15 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
{ {
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(TestContext.Current.CancellationToken); 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); command.SetPaymentAmounts(100m, 30m);
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken); await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
payload = Newtonsoft.Json.JsonConvert.DeserializeObject<HairCutQuery>( payload = Newtonsoft.Json.JsonConvert.DeserializeObject<HairCutQuery>(
@ -166,6 +176,11 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
Assert.Equal(100m, command.AmountDue); Assert.Equal(100m, command.AmountDue);
Assert.Equal(30m, command.AmountPaid); Assert.Equal(30m, command.AmountPaid);
Assert.False(command.GetIsAcquitted()); Assert.False(command.GetIsAcquitted());
Assert.NotNull(command.InvoiceEstimateId);
Assert.Equal("EUR", command.InvoiceCurrency);
Assert.Equal("alice", command.InvoiceClientId);
Assert.Equal("alice", command.InvoicePerformerId);
Assert.Equal(100m, command.GetBillItems().Sum(item => item.Count * item.UnitaryCost));
} }
} }

View file

@ -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<ApiWebServerFixture>
{
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<ApplicationDbContext>();
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<InvoiceResponse> 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<InvoiceResponse>(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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>(), 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<ApplicationDbContext>(), new VerifiedPayPalStub());
var error = await Assert.ThrowsAsync<InvoiceOperationException>(
() => 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<ApplicationDbContext>(
scope.ServiceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())
.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<ApplicationDbContext>(), 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<InvoiceOperationException>(() => first);
Assert.Equal(409, conflict.StatusCode);
using (var checkScope = fixture.Services.CreateScope())
{
var db = checkScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<ApplicationDbContext>();
var command = db.RdvQueries.Single(q => q.Id == commandId);
Assert.Throws<InvalidOperationException>(() => 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<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Started.TrySetResult();
await Resume.Task.WaitAsync(cancellationToken);
return result;
}
}
private sealed class VerifiedPayPalStub : IPayPalTransactionVerifier
{
public Task<VerifiedPayPalReceipt> 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));
}
}
}

View file

@ -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<string, string> 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<string, string> 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<InvoiceOperationException>(
() => 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<InvoiceOperationException>(
() => 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<InvoiceOperationException>(
() => 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<InvoiceOperationException>(
() => 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<OperationCanceledException>(
() => 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Uri = request.RequestUri;
Body = await request.Content!.ReadAsStringAsync(ct);
return new HttpResponseMessage(status) { Content = new StringContent(body) };
}
}
}