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:
parent
5d03b2e477
commit
2dcbcd35b3
37 changed files with 6686 additions and 12 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,6 +5,13 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [Yavsc.Api, Yavsc.Server, Yavsc.Api.Client] Creation explicite et
|
||||
idempotente d'une facture depuis un devis doublement signe, avec lignes,
|
||||
total et parties figes. Historique des encaissements hors ligne attestes
|
||||
par le prestataire et des transactions PayPal verifiees cote serveur ;
|
||||
references uniques, cumul atomique et protection contre les ecritures
|
||||
concurrentes. Routes documentees, client HTTP et migration associes.
|
||||
|
||||
* [Yavsc.Abstract, Yavsc.Server] Modele de solde des commandes facturables :
|
||||
montants dus et payes persistants, reste a payer et etats non paye,
|
||||
partiellement paye ou acquitte. L'acquittement depend des montants,
|
||||
|
|
@ -20,6 +27,11 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
* [Facturation] Les commandes facturees et leur devis ne peuvent plus etre
|
||||
supprimes via les routes de commande usuelles. Les additions distinguent
|
||||
montant verse, solde restant et acquittement ; les anciennes routes de
|
||||
generation exigent une facture etablie depuis un devis signe.
|
||||
|
||||
## [1.0.8-rc27] - unstable
|
||||
|
||||
### Added
|
||||
|
|
|
|||
126
doc/invoicing.md
126
doc/invoicing.md
|
|
@ -127,8 +127,8 @@ Implémentée sur le point d'entrée existant `IBillable` /
|
|||
La migration ajoute les montants sans reconstituer de paiements historiques :
|
||||
les anciennes commandes ont un dû inconnu et un payé nul. Un rapprochement
|
||||
explicite est nécessaire avant de les considérer comme acquittées.
|
||||
La génération depuis le devis, la fixation du dû et l'alimentation du payé
|
||||
par des transactions confirmées restent à brancher aux étapes suivantes.
|
||||
La génération depuis le devis et l'alimentation du payé sont désormais
|
||||
branchées par le service `InvoicingService` (étapes 2 et 4 ci-dessous).
|
||||
L'appel `SetPaymentAmounts` remplace un total cumulé ; il n'enregistre pas
|
||||
une transaction et ne doit pas être utilisé pour additionner un retour PayPal
|
||||
sans contrôle d'idempotence.
|
||||
|
|
@ -142,9 +142,33 @@ Objectifs couverts :
|
|||
|
||||
### Étape 2 — brancher la génération de facture sur le devis signé
|
||||
|
||||
- vérifier que le devis est bien doublement validé,
|
||||
- déclencher la facture depuis le contexte du devis,
|
||||
- rattacher la facture au bon client / prestataire.
|
||||
Implémentée par `POST /api/v1/invoices/from-estimate/{estimateId}` :
|
||||
|
||||
- réservée au prestataire du devis (ou à un administrateur) ;
|
||||
- exige les deux dates de validation **et** les deux signatures persistées,
|
||||
non vides, associées aux bons signataires ; une date seule ne suffit pas ;
|
||||
- exige une commande existante, dont le client et le prestataire correspondent
|
||||
au devis ; les devis sans commande restent des devis, non des factures ;
|
||||
- copie les lignes du devis, sa devise EUR, son total et ses parties dans
|
||||
un instantané de facture sur la commande, daté côté serveur ;
|
||||
- accepte les remises mais refuse un total négatif, les quantités non positives,
|
||||
les prix infra-centime et les devis multidevises ;
|
||||
- initialise le payé à zéro, sans assimiler un ancien checkout à un encaissement ;
|
||||
- renvoie la même facture lors d'un nouvel appel pour le même devis, sans
|
||||
changer le total ou effacer les paiements ; une autre facture ou un solde
|
||||
historique préexistant sur la commande provoque un conflit de rapprochement.
|
||||
|
||||
`GET /api/v1/invoices/{commandId}` expose l'instantané et l'historique aux
|
||||
parties de la facture et aux administrateurs. Les statuts sont des chaînes
|
||||
`Unpaid`, `PartiallyPaid` et `Paid`. Les lignes utilisées par `GetBillItems()`
|
||||
sont celles de la facture figée, même si le devis est modifié ensuite.
|
||||
Les commandes facturées et leurs devis ne sont plus supprimables par les
|
||||
routes de commande concernées. Le changement de prestataire est bloqué sur
|
||||
les routes usuelles de modification ; l'instantané reste la référence des parties.
|
||||
|
||||
Cette création établit la facture métier et ses montants, **pas un PDF** :
|
||||
le moteur historique de rendu documentaire reste distinct. Ses routes de
|
||||
génération exigent désormais une facture établie.
|
||||
|
||||
### Étape 3 — intégrer le mode de contrat du prestataire
|
||||
|
||||
|
|
@ -154,9 +178,71 @@ Objectifs couverts :
|
|||
|
||||
### Étape 4 — enregistrer les paiements sans surinterpréter le statut
|
||||
|
||||
- enregister le paiement effectué,
|
||||
- alimenter les montants payés et restants,
|
||||
- ne pas considérer la facture comme totalement acquittée tant que le total n'est pas atteint.
|
||||
Implémentée par deux sources d'encaissement, sans débit déclenché par ces routes :
|
||||
|
||||
| Route POST sous `/api/v1/invoices/{commandId}` | Source de vérité | Accès |
|
||||
| --- | --- | --- |
|
||||
| `/payments/offline` | Attestation du prestataire : référence, montant, devise, date de réception | Prestataire ou administrateur |
|
||||
| `/payments/paypal` | Réponse serveur PayPal `GetTransactionDetails` pour un identifiant de transaction | Parties ou administrateur |
|
||||
|
||||
Le reçu hors ligne doit être strictement positif, en centimes EUR, avec une
|
||||
référence non vide et une date non future. Il est attesté, pas vérifié auprès
|
||||
de la banque. La référence est unique par prestataire ; le client ne peut
|
||||
pas s'auto-déclarer acquitté.
|
||||
|
||||
Pour PayPal, seul l'identifiant est accepté du client. Le serveur vérifie :
|
||||
|
||||
- statut `Completed`, et non un simple `ACK=Success` ou un statut `Pending` ;
|
||||
- identifiant exact, référence de facture `TypeCommande/Id` (`INVNUM`),
|
||||
devise de la facture et compte marchand destinataire (`RECEIVERID`) ;
|
||||
- montant brut reçu strictement positif et date retournés par PayPal.
|
||||
|
||||
Le token de checkout historique n'est **pas** un identifiant de transaction.
|
||||
Le retour historique `ConfirmPayment` ne constitue toujours pas une preuve
|
||||
d'encaissement et n'alimente pas le solde. Cette intégration vérifie des
|
||||
transactions déjà encaissées ; elle n'ajoute ni capture, ni webhook, ni
|
||||
automatisation du retour navigateur. Le protocole NVP reprend le système
|
||||
Classic déjà utilisé par le dépôt ; il est déprécié par PayPal et réservé
|
||||
aux marchands existants compatibles.
|
||||
|
||||
Chaque encaissement conserve sa référence, son origine, son montant, sa
|
||||
devise, ses dates de réception/enregistrement et l'identifiant de l'acteur.
|
||||
La référence PayPal est unique globalement : une même transaction ne peut
|
||||
pas solder deux factures. Rejouer un reçu identique ne l'ajoute pas deux fois ;
|
||||
réutiliser sa référence avec d'autres détails renvoie `409`.
|
||||
|
||||
L'ajout au journal et la mise à jour du total payé sont sauvegardés dans la
|
||||
même transaction EF relationnelle. Un jeton de concurrence sur la commande
|
||||
empêche les pertes de mise à jour. En cas de conflit concurrent, la route
|
||||
renvoie `409` : rejouer **la même référence et le même corps** permet de
|
||||
réessayer sans double comptage. Un solde ne correspondant pas au journal
|
||||
requiert un rapprochement, sans réinitialisation silencieuse.
|
||||
|
||||
Le solde est calculé à partir des reçus cumulés. Un paiement partiel garde
|
||||
la facture non acquittée ; un trop-perçu est conservé avec un restant nul.
|
||||
Le texte de l'addition affiche payé, restant et statut.
|
||||
|
||||
#### Configuration et utilisation
|
||||
|
||||
Appliquer la migration `SignedEstimateInvoicing` avant déploiement.
|
||||
Elle ajoute l'instantané, le jeton de concurrence et le journal `InvoicePayments`,
|
||||
sans créer de facture ni de reçu pour les données historiques.
|
||||
|
||||
La vérification PayPal utilise la section `Authentication:PayPal` :
|
||||
`Mode` (`sandbox` ou `production`), `MerchantAccountId` et
|
||||
`MerchantAccountUserName`. Une entrée de `Accounts` doit correspondre à ce
|
||||
dernier via `ApiUsername`, avec `ApiPassword` et `Signature`.
|
||||
Conserver les identifiants dans les secrets ou l'environnement, jamais dans
|
||||
un fichier versionné. Sans configuration exploitable, la route renvoie `503`
|
||||
sans enregistrer de paiement ; une panne ou une réponse invalide PayPal
|
||||
renvoie `502`/`504`, sans modifier le solde.
|
||||
|
||||
Le client partagé `InvoicesApiClient` expose les quatre opérations.
|
||||
Le document `/openapi/invoicing.json` est réservé aux administrateurs en
|
||||
développement, comme les autres documents OpenAPI du serveur.
|
||||
Des exemples de requêtes sont fournis dans `src/Yavsc.Api/Invoicing.http` ;
|
||||
adapter l'adresse du serveur et les jetons. Aucun écran PostIt n'est ajouté
|
||||
dans ce lot.
|
||||
|
||||
### Étape 5 — finaliser l'état acquitté
|
||||
|
||||
|
|
@ -164,8 +250,32 @@ Objectifs couverts :
|
|||
- si `totalPaye >= totalDu`, alors passer la facture en état acquittée,
|
||||
- envoyer les notifications / documents associés au cas échéant.
|
||||
|
||||
Le calcul du statut est branché aux encaissements. Les notifications finales,
|
||||
le rendu documentaire, les remboursements/annulations de reçus et les
|
||||
rapprochements historiques restent hors de ce lot, tout comme l'étape 3.
|
||||
Un remboursement ou litige ultérieur n'est pas synchronisé automatiquement :
|
||||
ne pas utiliser ce journal d'encaissements comme gestion complète des
|
||||
remboursements PayPal.
|
||||
|
||||
## Tests à couvrir
|
||||
|
||||
Les tests implémentés du backend s'exécutent avec le runner MTP du dépôt :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
|
||||
--filter-class '*InvoiceApiTests' --filter-class '*HairCutQueryApiControllerTests'
|
||||
dotnet test --project src/test/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj \
|
||||
--filter-class '*PaymentSummaryTests' --filter-class '*PayPalTransactionVerifierTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*InvoicesApiClientTests'
|
||||
```
|
||||
|
||||
Les tests HTTP utilisent la base relationnelle isolée de la fixture.
|
||||
Ils vérifient notamment qu'un ajout concurrent perdant est entièrement
|
||||
annulé (journal et total), puis rejouable. Les tests PayPal utilisent des
|
||||
réponses HTTP simulées, sans identifiants réels ni appel au prestataire.
|
||||
Ils ne remplacent pas une recette sandbox sur un compte Classic compatible.
|
||||
|
||||
Le flux doit être validé par des tests ciblés sur :
|
||||
|
||||
- devis signé par les deux parties → génération d'une facture non acquittée,
|
||||
|
|
|
|||
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal file
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ public static class ServiceCollectionHelpers
|
|||
() => settings.Authentication?.Authority);
|
||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||
var invoicesClient = new InvoicesApiClient(api, () => settings.ApiUrl);
|
||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
|
||||
|
|
@ -104,6 +105,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(activityClient);
|
||||
services.AddSingleton(billingClient);
|
||||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(invoicesClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(administrationClient);
|
||||
|
|
|
|||
38
src/Yavsc.Abstract/Billing/InvoiceDtos.cs
Normal file
38
src/Yavsc.Abstract/Billing/InvoiceDtos.cs
Normal 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; }
|
||||
}
|
||||
50
src/Yavsc.Api.Client/InvoicesApiClient.cs
Normal file
50
src/Yavsc.Api.Client/InvoicesApiClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers
|
|||
{
|
||||
return new ChallengeResult();
|
||||
}
|
||||
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null })
|
||||
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
|
||||
Response.ContentType = "text/x-tex";
|
||||
return ViewComponent("Bill",new object[] { billingCode, bill , OutputFormat.LaTeX, true });
|
||||
}
|
||||
|
|
@ -103,6 +105,10 @@ namespace Yavsc.ApiControllers
|
|||
return this.NotFound();
|
||||
}
|
||||
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
|
||||
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice)
|
||||
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
|
||||
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName))
|
||||
return Forbid();
|
||||
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,8 @@ namespace Yavsc.Controllers
|
|||
return BadRequest(ModelState);
|
||||
}
|
||||
}
|
||||
if (_context.NominativeServiceCommands.Any(command => command.InvoiceEstimateId == id))
|
||||
return Conflict(new { Error = "An invoiced estimate cannot be deleted." });
|
||||
_context.Estimates.Remove(estimate);
|
||||
_context.SaveChanges(User.GetUserId());
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@ public class HairCutQueryApiController : Controller
|
|||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
|
||||
return Conflict(new { Error = "An invoiced command cannot change performer." });
|
||||
|
||||
existing.ActivityCode = query.ActivityCode;
|
||||
existing.PerformerId = query.PerformerId;
|
||||
existing.Consent = query.Consent;
|
||||
|
|
@ -187,6 +190,8 @@ public class HairCutQueryApiController : Controller
|
|||
return Forbid();
|
||||
}
|
||||
|
||||
if (query.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.HairCutQueries.Remove(query);
|
||||
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ public class HairMultiCutQueryApiController : Controller
|
|||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
|
||||
return Conflict(new { Error = "An invoiced command cannot change performer." });
|
||||
_context.RemoveRange(existing.Prestations);
|
||||
existing.ActivityCode = query.ActivityCode;
|
||||
existing.PerformerId = query.PerformerId;
|
||||
|
|
@ -203,6 +205,8 @@ public class HairMultiCutQueryApiController : Controller
|
|||
return Forbid();
|
||||
}
|
||||
|
||||
if (query.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.RemoveRange(query.Prestations);
|
||||
_context.HairMultiCutQueries.Remove(query);
|
||||
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||
|
|
|
|||
59
src/Yavsc.Api/Controllers/Business/InvoicesController.cs
Normal file
59
src/Yavsc.Api/Controllers/Business/InvoicesController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,9 @@ public class RdvQueryApiController : Controller
|
|||
return Forbid();
|
||||
}
|
||||
|
||||
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
|
||||
return Conflict(new { Error = "An invoiced command cannot change performer." });
|
||||
|
||||
existing.ActivityCode = query.ActivityCode;
|
||||
existing.PerformerId = query.PerformerId;
|
||||
existing.Consent = query.Consent;
|
||||
|
|
@ -224,6 +227,8 @@ public class RdvQueryApiController : Controller
|
|||
return Forbid();
|
||||
}
|
||||
|
||||
if (query.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.RdvQueries.Remove(query);
|
||||
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -88,9 +88,22 @@ namespace Yavsc.ApiControllers
|
|||
return BadRequest();
|
||||
}
|
||||
|
||||
var persisted = await _context.HairCutQueries.AsNoTracking().SingleOrDefaultAsync(q => q.Id == id);
|
||||
if (persisted is null) return NotFound();
|
||||
if (persisted.InvoiceEstimateId.HasValue
|
||||
&& (persisted.ClientId != hairCutQuery.ClientId || persisted.PerformerId != hairCutQuery.PerformerId))
|
||||
return Conflict(new { Error = "An invoiced command cannot change parties." });
|
||||
_context.Entry(hairCutQuery).State = EntityState.Modified;
|
||||
_context.Entry(hairCutQuery).Property(q => q.PaymentRevision).OriginalValue = persisted.PaymentRevision;
|
||||
_context.Entry(hairCutQuery).Property(q => q.AmountDue).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.AmountPaid).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoiceEstimateId).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoicedAtUtc).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoiceCurrency).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoiceClientId).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoicePerformerId).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.InvoiceLinesJson).IsModified = false;
|
||||
_context.Entry(hairCutQuery).Property(q => q.PaymentRevision).IsModified = false;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -206,6 +219,8 @@ namespace Yavsc.ApiControllers
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (hairCutQuery.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.HairCutQueries.Remove(hairCutQuery);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
|
|
|||
51
src/Yavsc.Api/Invoicing.http
Normal file
51
src/Yavsc.Api/Invoicing.http
Normal 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"
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ internal class Program
|
|||
// the document-generation flow and BillingController.
|
||||
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
|
||||
services.AddPodcasts(builder.Configuration);
|
||||
services.AddInvoicing(builder.Configuration);
|
||||
|
||||
// accepts any access token issued by identity server
|
||||
services.AddAuthentication("Bearer")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,8 @@ namespace Yavsc.Controllers
|
|||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (query.ClientId != uid)
|
||||
return new ChallengeResult();
|
||||
if (query.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.HairCutQueries.Remove(query);
|
||||
await _context.SaveChangesAsync();
|
||||
return await Index();
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ namespace Yavsc.Controllers
|
|||
public async Task<IActionResult> DeleteConfirmed(long id)
|
||||
{
|
||||
Project project = await _context.Project.SingleAsync(m => m.Id == id);
|
||||
if (project.InvoiceEstimateId.HasValue)
|
||||
return Conflict(new { Error = "An invoiced command cannot be deleted." });
|
||||
_context.Project.Remove(project);
|
||||
await _context.SaveChangesAsync();
|
||||
return RedirectToAction("Index");
|
||||
|
|
|
|||
4966
src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.Designer.cs
generated
Normal file
4966
src/Yavsc.Org/Migrations/20261019184909_SignedEstimateInvoicing.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1442,6 +1442,61 @@ namespace Yavsc.Migrations
|
|||
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 =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
|
@ -1484,9 +1539,34 @@ namespace Yavsc.Migrations
|
|||
.HasMaxLength(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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("PaymentRevision")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
|
||||
b.Property<string>("PerformerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
@ -1514,6 +1594,9 @@ namespace Yavsc.Migrations
|
|||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("InvoiceEstimateId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PaymentId");
|
||||
|
||||
b.HasIndex("PerformerId");
|
||||
|
|
@ -3953,6 +4036,17 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
|
||||
.WithMany()
|
||||
.HasForeignKey("CommandId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Command");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
|
||||
|
|
@ -3967,6 +4061,11 @@ namespace Yavsc.Migrations
|
|||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Yavsc.Models.Billing.Estimate", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InvoiceEstimateId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
|
||||
.WithMany()
|
||||
.HasForeignKey("PaymentId");
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ namespace Yavsc.Helpers
|
|||
string total = query.GetBillItems().Addition().ToString("C", CultureInfo.CurrentUICulture);
|
||||
string bill = string.Join("\n", query.GetBillItems().Select(l=> $"{l.Name} {l.Description} : {l.UnitaryCost} € " + ((l.Count != 1) ? "*"+l.Count.ToString() : ""))) +
|
||||
$"\n\nTotal: {total}";
|
||||
if (query.PaymentSummary is { } payment)
|
||||
{
|
||||
bill += $"\nPaid: {payment.AmountPaid.ToString("C", CultureInfo.CurrentUICulture)}"
|
||||
+ $"\nRemaining: {payment.AmountRemaining.ToString("C", CultureInfo.CurrentUICulture)}"
|
||||
+ $"\nStatus: {payment.Status}";
|
||||
}
|
||||
return bill;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using Yavsc.ViewModels.PayPal;
|
|||
using Yavsc.Models;
|
||||
using Yavsc.Models.Payment;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Yavsc.Helpers
|
||||
{
|
||||
|
|
@ -72,7 +73,7 @@ namespace Yavsc.Helpers
|
|||
public static SetExpressCheckoutResponseType CreatePayment(this HttpRequest request, string controllerName, NominativeServiceCommand query, string intent = "sale", ILogger logger = null)
|
||||
{
|
||||
var items = query.GetBillItems();
|
||||
var total = items.Addition().ToString("F2");
|
||||
var total = items.Addition().ToString("F2", CultureInfo.InvariantCulture);
|
||||
var coreq = new SetExpressCheckoutReq {};
|
||||
var urls = request.GetPaymentUrls(controllerName, query.Id.ToString());
|
||||
|
||||
|
|
@ -89,7 +90,7 @@ namespace Yavsc.Helpers
|
|||
},
|
||||
PaymentDetailsItem = new List <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,
|
||||
Quantity = i.Count,
|
||||
Description = i.Description
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ namespace Yavsc.Helpers
|
|||
((db, id) =>
|
||||
{
|
||||
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id);
|
||||
if (!query.InvoiceEstimateId.HasValue)
|
||||
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
|
||||
return query;
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -61,6 +61,22 @@ namespace Yavsc.Models
|
|||
builder.Entity<NominativeServiceCommand>()
|
||||
.Property(command => command.AmountPaid)
|
||||
.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 =>
|
||||
{
|
||||
|
|
@ -358,6 +374,7 @@ namespace Yavsc.Models
|
|||
public DbSet<BlogTag> TagsDomain { get; set; }
|
||||
|
||||
public DbSet<EstimateTemplate> EstimateTemplates { get; set; }
|
||||
public DbSet<InvoicePayment> InvoicePayments { get; set; }
|
||||
|
||||
public DbSet<Contact> Contact { get; set; }
|
||||
|
||||
|
|
|
|||
28
src/Yavsc.Server/Models/Billing/InvoicePayment.cs
Normal file
28
src/Yavsc.Server/Models/Billing/InvoicePayment.cs
Normal 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; } = "";
|
||||
}
|
||||
|
|
@ -88,6 +88,50 @@ namespace Yavsc.Models.Billing
|
|||
|
||||
public decimal AmountPaid { get; private set; }
|
||||
|
||||
public long? InvoiceEstimateId { get; private set; }
|
||||
public DateTimeOffset? InvoicedAtUtc { get; private set; }
|
||||
[MaxLength(3)]
|
||||
public string? InvoiceCurrency { get; private set; }
|
||||
public string? InvoiceClientId { get; private set; }
|
||||
public string? InvoicePerformerId { get; private set; }
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? InvoiceLinesJson { get; private set; }
|
||||
[ConcurrencyCheck]
|
||||
public Guid PaymentRevision { get; private set; }
|
||||
|
||||
public void EstablishInvoice(long estimateId, string currency, IReadOnlyList<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]
|
||||
public PaymentSummary? PaymentSummary => AmountDue.HasValue
|
||||
? new PaymentSummary(AmountDue.Value, AmountPaid)
|
||||
|
|
@ -95,9 +139,12 @@ namespace Yavsc.Models.Billing
|
|||
|
||||
public void SetPaymentAmounts(decimal amountDue, decimal amountPaid)
|
||||
{
|
||||
if (InvoiceEstimateId.HasValue && AmountDue != amountDue)
|
||||
throw new InvalidOperationException("An established invoice total cannot be changed.");
|
||||
var summary = new PaymentSummary(amountDue, amountPaid);
|
||||
AmountDue = summary.AmountDue;
|
||||
AmountPaid = summary.AmountPaid;
|
||||
PaymentRevision = Guid.NewGuid();
|
||||
}
|
||||
|
||||
public bool GetIsAcquitted()
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ namespace Yavsc.Models.Haircut
|
|||
|
||||
public override List<IBillItem> GetBillItems()
|
||||
{
|
||||
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
|
||||
string longhairsuffix = " (cheveux longs)";
|
||||
string halflonghairsuffix = " (cheveux mi-longs)";
|
||||
string shorthairsuffix = " (cheveux courts)";
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ namespace Yavsc.Models.Haircut
|
|||
|
||||
public override List<IBillItem> GetBillItems()
|
||||
{
|
||||
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ namespace Yavsc.Server.Models.IT
|
|||
}
|
||||
public override List<IBillItem> GetBillItems()
|
||||
{
|
||||
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
|
||||
return bill;
|
||||
}
|
||||
|
||||
|
|
@ -62,4 +63,3 @@ namespace Yavsc.Server.Models.IT
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ namespace Yavsc.Models.Workflow
|
|||
|
||||
public override List<IBillItem> GetBillItems()
|
||||
{
|
||||
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
191
src/Yavsc.Server/Services/InvoicingService.cs
Normal file
191
src/Yavsc.Server/Services/InvoicingService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
77
src/Yavsc.Server/Services/PayPalTransactionVerifier.cs
Normal file
77
src/Yavsc.Server/Services/PayPalTransactionVerifier.cs
Normal 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("+", " "));
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
builder.Services.AddInvoicing(builder.Configuration);
|
||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Yavsc.ApiControllers;
|
||||
using Yavsc.Billing;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Haircut;
|
||||
|
|
@ -140,6 +141,15 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
|
|||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var command = await db.HairCutQueries.SingleAsync(TestContext.Current.CancellationToken);
|
||||
var estimate = new Yavsc.Models.Billing.Estimate
|
||||
{
|
||||
CommandId = command.Id, ClientId = command.ClientId, OwnerId = command.PerformerId,
|
||||
CommandType = "Brush", Title = "Invoice test", Description = "Invoice test",
|
||||
Bill = [], AttachedFiles = [], AttachedGraphics = []
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
|
||||
command.EstablishInvoice(estimate.Id, "EUR", [new InvoiceLineResponse("Service", "Service", 1, 100m)]);
|
||||
command.SetPaymentAmounts(100m, 30m);
|
||||
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
|
||||
payload = Newtonsoft.Json.JsonConvert.DeserializeObject<HairCutQuery>(
|
||||
|
|
@ -166,6 +176,11 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
|
|||
Assert.Equal(100m, command.AmountDue);
|
||||
Assert.Equal(30m, command.AmountPaid);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
449
src/test/Yavsc.Api.Tests/InvoiceApiTests.cs
Normal file
449
src/test/Yavsc.Api.Tests/InvoiceApiTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/test/Yavsc.Server.Tests/PayPalTransactionVerifierTests.cs
Normal file
153
src/test/Yavsc.Server.Tests/PayPalTransactionVerifierTests.cs
Normal 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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue