backend phase 6: nurse verification & credentials (mocked vendors)

The trust engine. New `verif` schema (5 tables) + a data-driven verification
pipeline: steps are rows (6 seeded step-types), not a code enum.

- nurse_verifications.status is the single source of verification truth;
  nurse_profiles.is_verified is flipped ONLY inside the finalize transaction
  (VerificationAggregator: tracked verification + tracked profile -> one commit)
  and reversed on suspension/expiry — no in-between state.
- is_automated snapshotted onto each step at submit; steps seeded from active
  required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership)
  find their step by code.
- users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner
  compare against it (money-mule guard); shared-SIM -> shared_sim support alert.
- Documents are metadata-only behind signed URLs; credential_number encrypted
  and never serialized; public trust badge exposes credential TYPES, not numbers;
  holder-name cross-checked against the verified identity before recording.
- Admin-triggered credential-expiry scan reverts lapsed steps, re-gates
  bookability, raises a verification_expired alert + verification_expiry_prompt
  notification (scheduled cron deferred; config key
  verification_expiry_scan_cadence_hours).

Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider /
ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and
b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers.

Two migrations (tables + step-type seed). 154 tests pass, zero new warnings.
Contract dev/contracts/domains/verification.md + swagger snapshot refreshed;
handoff/report/mocks-registry updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:39:32 +03:30
parent 687fbfc6d9
commit 1c266523bc
105 changed files with 13938 additions and 10 deletions
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.Verification;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Default <see cref="ICredentialVerifier"/> — and the mock. MoH پروانه صلاحیت حرفه‌ای and INO membership
/// have <b>no public B2B API</b>, so verification is a manual admin review of the uploaded document against
/// the official portal: every call returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
/// with <c>verification_method = manual</c>. When an <c>api</c>/<c>portal</c> source becomes available, a
/// real implementation replaces this registration and starts returning
/// <see cref="CredentialVerificationStatus.Verified"/>/<see cref="CredentialVerificationStatus.Failed"/>
/// with the matching method — callers are unchanged.
/// </summary>
public sealed class MockCredentialVerifier : ICredentialVerifier
{
public Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default)
=> Task.FromResult(new CredentialVerificationResult(
CredentialVerificationStatus.RequiresManualReview,
VerificationMethods.Manual,
ExternalResponseJson: null));
}
@@ -0,0 +1,57 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IIdentityKycProvider"/>: a deterministic fake identity + liveness check — no real
/// OCR/liveness call. Passes every well-formed national id except the configured
/// <see cref="IdentityKycOptions.FailNationalId"/>. On pass it reports a matched name and populates the
/// verified identity. A real Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify /
/// Kavoshak) swaps in by a registration change — callers are unchanged.
/// </summary>
public sealed class MockIdentityKycProvider(IOptions<SeamOptions> options) : IIdentityKycProvider
{
private readonly IdentityKycOptions _options = options.Value.IdentityKyc;
public Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
{
var id = (nationalId ?? string.Empty).Trim();
var vendorRef = $"MOCK-KYC-{Token(id)}";
var wellFormed = id.Length == 10 && id.All(char.IsDigit);
if (!wellFormed || string.Equals(id, _options.FailNationalId, StringComparison.Ordinal))
{
return Task.FromResult(new IdentityKycResult(
Passed: false,
MatchedName: null,
VendorRef: vendorRef,
ExternalResponseJson: Payload("fail", id, livenessPayload, passed: false),
FailureReason: "Identity could not be verified against the civil registry."));
}
return Task.FromResult(new IdentityKycResult(
Passed: true,
MatchedName: _options.MatchedName,
VendorRef: vendorRef,
ExternalResponseJson: Payload("pass", id, livenessPayload, passed: true),
FailureReason: null));
}
private static string Payload(string outcome, string nationalId, string? liveness, bool passed)
=> JsonSerializer.Serialize(new
{
provider = "mock_identity_kyc",
outcome,
passed,
national_id_present = !string.IsNullOrEmpty(nationalId),
liveness_present = !string.IsNullOrEmpty(liveness)
});
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -0,0 +1,68 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IShahkarVerifier"/>: a deterministic fake شاهکار phone↔national-id inquiry — no real
/// Shahkar/KYC call. Matches every pair except the configured <see cref="ShahkarOptions.SharedSimPhone"/>
/// (returns the explicit shared-SIM failure state) and <see cref="ShahkarOptions.MismatchNationalId"/>
/// (returns a plain mismatch). The vendor ref is derived from the inputs, so re-running is idempotent. A
/// real Finnotech/KYC client swaps in by a registration change — callers are unchanged.
/// </summary>
public sealed class MockShahkarVerifier(IOptions<SeamOptions> options) : IShahkarVerifier
{
private readonly ShahkarOptions _options = options.Value.Shahkar;
public Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
{
var phone = (phoneNumber ?? string.Empty).Trim();
var vendorRef = $"MOCK-SHAHKAR-{Token(phone + "|" + (nationalId ?? string.Empty))}";
if (string.Equals(phone, _options.SharedSimPhone, StringComparison.Ordinal))
{
var json = Payload("shared_sim", phone, nationalId, matched: false);
return Task.FromResult(new ShahkarMatchResult(
Matched: false,
IsSharedSim: true,
VendorRef: vendorRef,
ExternalResponseJson: json,
FailureReason: "This SIM appears to be registered to a family member. Please use a SIM registered in your own name."));
}
if (string.Equals(nationalId, _options.MismatchNationalId, StringComparison.Ordinal))
{
var json = Payload("mismatch", phone, nationalId, matched: false);
return Task.FromResult(new ShahkarMatchResult(
Matched: false,
IsSharedSim: false,
VendorRef: vendorRef,
ExternalResponseJson: json,
FailureReason: "The phone number is not registered to your national ID."));
}
return Task.FromResult(new ShahkarMatchResult(
Matched: true,
IsSharedSim: false,
VendorRef: vendorRef,
ExternalResponseJson: Payload("match", phone, nationalId, matched: true),
FailureReason: null));
}
private static string Payload(string outcome, string phone, string? nationalId, bool matched)
=> JsonSerializer.Serialize(new
{
provider = "mock_shahkar",
outcome,
matched,
phone_last4 = phone.Length >= 4 ? phone[^4..] : phone,
national_id_present = !string.IsNullOrEmpty(nationalId)
});
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -12,6 +12,38 @@ public sealed class SeamOptions
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
public ShahkarOptions Shahkar { get; set; } = new();
public IdentityKycOptions IdentityKyc { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IShahkarVerifier</c> (phone↔national-id binding). A submitted phone equal to
/// <see cref="SharedSimPhone"/> returns the explicit shared-SIM failure; a national id equal to
/// <see cref="MismatchNationalId"/> returns a plain mismatch; every other pair matches. The real vendor
/// implementation ignores these.
/// </summary>
public sealed class ShahkarOptions
{
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
public string SharedSimPhone { get; set; } = "09120000000";
/// <summary>The designated test national id that returns a plain phone↔national-id mismatch.</summary>
public string MismatchNationalId { get; set; } = "1111111111";
}
/// <summary>
/// Tunes the mock <c>IIdentityKycProvider</c>. A national id equal to <see cref="FailNationalId"/> fails
/// KYC; every other (well-formed) national id passes with <see cref="MatchedName"/>. The real e-KYC vendor
/// implementation ignores these.
/// </summary>
public sealed class IdentityKycOptions
{
/// <summary>The designated test national id that fails identity KYC.</summary>
public string FailNationalId { get; set; } = "0000000000";
/// <summary>The name the mock reports as matched on a passing KYC (informational; the authoritative
/// identity name for credential cross-check comes from the users row).</summary>
public string MatchedName { get; set; } = "Verified Nurse";
}
/// <summary>
@@ -36,6 +36,13 @@ public static class ServiceCollectionExtension
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
// Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
// e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
// no mock behaviour is baked into any handler call site.
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
return services;
}
}