backend phase 10
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// A distributed mutex around a money-path critical section (real impl: StackExchange.Redis with a lease/
|
||||
/// expiry, key convention <c>booking:{id}:payment</c>). It is the <b>fast first line</b> so a double callback
|
||||
/// and a user retry don't both start a money mutation — but <b>never the sole correctness guarantee</b>: if
|
||||
/// Redis is down or the lease expires, the DB uniques/state-machine remain the authoritative backstop.
|
||||
/// </summary>
|
||||
public interface IDistributedLock
|
||||
{
|
||||
/// <summary>Acquires the lock for <paramref name="key"/>; dispose the handle to release. The mock is an
|
||||
/// in-process semaphore per key, so the money-path code runs the same shape it will with real Redis.</summary>
|
||||
ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The swappable card-PSP acquirer seam (ZarinPal / Sadad / Vandar / Jibit …). Handlers depend only on this
|
||||
/// contract; the concrete provider is selected from <c>payment_gateways</c> config so a cut-off provider is
|
||||
/// swapped without a code change. <b>Every amount crossing this seam is IRR <c>long</c></b> — Toman conversion
|
||||
/// happens only inside a real adapter at its boundary, never here.
|
||||
/// </summary>
|
||||
public interface IPaymentProvider
|
||||
{
|
||||
/// <summary>Starts an IPG session and returns the redirect URL plus a deterministic gateway reference to
|
||||
/// persist on the pending transaction (honouring its filtered unique). <paramref name="idempotencyKey"/>
|
||||
/// makes a retried initiate return the same reference rather than opening a second session.</summary>
|
||||
ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The mandatory server-side re-check (<b>never trust a callback alone</b>): re-verifies the
|
||||
/// amount + reference against the gateway before a success callback is allowed to confirm.</summary>
|
||||
ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reverses a captured payment (partial or full). Exposed here so b11 refunds can call it; this
|
||||
/// phase builds no refund flow.</summary>
|
||||
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The outcome verb of a provider verify/refund — mirrors the wire <c>payment</c> status codes.</summary>
|
||||
public enum PaymentProviderStatus
|
||||
{
|
||||
Pending,
|
||||
Succeeded,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="RedirectUrl">Where the client is sent to complete the card payment.</param>
|
||||
/// <param name="GatewayReferenceCode">The deterministic reference persisted on the pending transaction.</param>
|
||||
public sealed record PaymentInitResult(string RedirectUrl, string GatewayReferenceCode);
|
||||
|
||||
/// <param name="Status">The re-verified outcome — only <see cref="PaymentProviderStatus.Succeeded"/> confirms.</param>
|
||||
/// <param name="AmountIrr">The amount the gateway reports, re-checked against the stored transaction.</param>
|
||||
public sealed record PaymentVerifyResult(PaymentProviderStatus Status, long AmountIrr);
|
||||
|
||||
/// <param name="Status">Whether the reversal was accepted by the gateway.</param>
|
||||
/// <param name="GatewayRefundReference">The provider's refund reference, when issued.</param>
|
||||
public sealed record PaymentRefundResult(PaymentProviderStatus Status, string? GatewayRefundReference);
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The تسهیم (settlement-sharing) seam — the <b>lawful split primitive</b>. A پرداختیار may not custody funds
|
||||
/// or run a wallet, so the platform never moves money between merchants: it registers a split-by-ratio to the
|
||||
/// beneficiaries' <b>registered IBANs</b> and the provider credits each IBAN directly. The ledger only mirrors
|
||||
/// money that legally sits at the provider/bank. Amounts are IRR <c>long</c>.
|
||||
/// </summary>
|
||||
public interface ISettlementSplitProvider
|
||||
{
|
||||
/// <summary>Registers the split for a captured booking. <paramref name="legs"/> credit the nurse's payout
|
||||
/// and the platform's commission to their registered IBANs; their sum equals the captured gross.</summary>
|
||||
ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> legs, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<SettlementResult> GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public enum SettlementStatus
|
||||
{
|
||||
Registered,
|
||||
Settled,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="Sheba">The beneficiary's registered IBAN (SHEBA) the provider credits directly.</param>
|
||||
/// <param name="AmountIrr">The IRR amount for this beneficiary.</param>
|
||||
/// <param name="Beneficiary">A label — <c>nurse</c> / <c>platform</c>.</param>
|
||||
public sealed record SettlementLeg(string Sheba, long AmountIrr, string Beneficiary);
|
||||
|
||||
public sealed record SettlementResult(SettlementStatus Status);
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an inbound PSP/BNPL callback's authenticity and extracts its idempotency key + payload. A callback
|
||||
/// is authenticated by <b>signature</b>, not a user session; an invalid signature must mutate <b>nothing</b>
|
||||
/// (stored with <c>signature_valid = 0</c>, <c>processing_status = ignored</c>). Where a provider offers no
|
||||
/// signature, the real adapter falls back to the mandatory server-side <c>verify</c> re-check.
|
||||
/// </summary>
|
||||
public interface IWebhookVerifier
|
||||
{
|
||||
WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> headers, string rawBody);
|
||||
}
|
||||
|
||||
/// <param name="SignatureValid">False ⇒ the callback is stored ignored and no money moves.</param>
|
||||
/// <param name="ExternalEventId">The provider's event id — half of the <c>(provider, external_event_id)</c> key.</param>
|
||||
/// <param name="EventType">The provider's event type; a success type triggers the confirm path.</param>
|
||||
/// <param name="GatewayReferenceCode">The reference tying the callback to a pending transaction.</param>
|
||||
/// <param name="IsSuccessEvent">Whether this event asserts a successful capture (gates the confirm path).</param>
|
||||
public sealed record WebhookVerification(
|
||||
bool SignatureValid,
|
||||
string ExternalEventId,
|
||||
string EventType,
|
||||
string? GatewayReferenceCode,
|
||||
bool IsSuccessEvent);
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -20,6 +21,10 @@ public interface IBookingRepository
|
||||
/// a replayed conversion return the existing booking instead of creating a second one.</summary>
|
||||
Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The three frozen amounts + nurse for a booking — what b10's card-capture ledger group posts
|
||||
/// against when the booking already exists (idempotent confirm). NULL when absent.</summary>
|
||||
Task<BookingLedgerAmounts?> GetLedgerAmountsAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
// ---- detail + lists ----
|
||||
Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
|
||||
Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -49,4 +50,8 @@ public interface IBookingRequestRepository
|
||||
/// one projected read: ids + participant user ids, the engagement schedule, and the source data for the
|
||||
/// two frozen snapshots (variant + decrypted address). NULL when absent.</summary>
|
||||
Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The facts b10's <c>InitiatePayment</c> needs to validate a card attempt (owning customer,
|
||||
/// status, frozen payment window, gross to charge). NULL when absent.</summary>
|
||||
Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Entities.Payments;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The payments-core aggregate (gateways, transactions, webhook events, ledger). Writes load tracked rows;
|
||||
/// balance reads project a signed aggregate over the append-only ledger. Money is IRR <c>long</c> throughout.
|
||||
/// The DB uniques (Shaparak ref, succeeded-per-booking, the webhook idempotency key) are the authoritative
|
||||
/// money-path backstops — the handlers rely on them, not just on a pre-check.
|
||||
/// </summary>
|
||||
public interface IPaymentRepository
|
||||
{
|
||||
// ---- gateway selection ----
|
||||
/// <summary>The active gateway of <paramref name="type"/> with the lowest priority — config-driven
|
||||
/// selection so a cut-off provider is swapped without a code change. Null when none is configured.</summary>
|
||||
Task<long?> GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken);
|
||||
|
||||
Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken);
|
||||
|
||||
// ---- transactions ----
|
||||
Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether a booking created from this request already has a captured (succeeded) transaction —
|
||||
/// the initiate idempotency pre-check (the filtered succeeded-unique is the backstop).</summary>
|
||||
Task<bool> HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked pending transaction carrying <paramref name="gatewayReferenceCode"/> — the webhook
|
||||
/// re-verify + confirm loads it to bind the capture. Null when absent.</summary>
|
||||
Task<PaymentTransaction?> GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken);
|
||||
|
||||
Task<PaymentTransaction?> GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
// ---- webhook idempotency store ----
|
||||
/// <summary>The existing webhook event for this idempotency key, if any — a non-null result means a
|
||||
/// duplicate replay the handler no-ops on. Null on a brand-new event.</summary>
|
||||
Task<PaymentWebhookEvent?> GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken);
|
||||
|
||||
// ---- ledger ----
|
||||
/// <summary>Whether a posting group already exists for this payment transaction — makes the ledger post
|
||||
/// idempotent so a re-confirm never writes a second capture group.</summary>
|
||||
Task<bool> LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddLedgerEntriesAsync(IEnumerable<LedgerEntry> entries, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The IRR balance currently owed a nurse — the signed sum over <c>nurse_payable</c> legs
|
||||
/// (credit adds, debit subtracts). Pure ledger projection, never a stored column. This is what b13 reads.</summary>
|
||||
Task<long> GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public interface IUnitOfWork
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
public IBookingRepository BookingRepository { get; }
|
||||
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
|
||||
public IPaymentRepository PaymentRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#nullable enable
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Unicode;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Bookings;
|
||||
|
||||
/// <summary>
|
||||
/// The single place the confirmed <c>bookings</c> row (+ its reconciling sessions + the two frozen snapshots)
|
||||
/// is built from an <c>accepted_awaiting_payment</c> request. It holds the <b>conversion/amount logic</b> so
|
||||
/// both the b9 <c>ConvertRequestToBooking</c> path (mock-capture trigger) and the b10 real card-capture
|
||||
/// confirm path share it rather than duplicate it. Pure construction only — no DB, no commit, no capture, no
|
||||
/// current-user gate; the caller owns tenancy, capture, idempotency and persistence.
|
||||
/// </summary>
|
||||
internal static class BookingFactory
|
||||
{
|
||||
/// <summary>session_count comes from the variant (a single visit is 1).</summary>
|
||||
public static int SessionCount(BookingConversionSource source)
|
||||
=> source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
|
||||
|
||||
/// <summary>The charged gross (IRR) = variant price × session count. Frozen onto the booking at build time.</summary>
|
||||
public static long Gross(BookingConversionSource source)
|
||||
=> source.VariantSnapshot.Price * SessionCount(source);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the booking (status <c>confirmed</c>) with its ≥ 1 reconciling sessions and the two frozen
|
||||
/// snapshots. The three amounts derive from the snapshotted <paramref name="rate"/> via
|
||||
/// <see cref="BookingAmounts"/> so <c>gross = commission + payout</c> always holds.
|
||||
/// </summary>
|
||||
public static BookingEntity Create(
|
||||
BookingConversionSource source,
|
||||
decimal rate,
|
||||
DateTime now,
|
||||
long? pspFeeAmount,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer)
|
||||
{
|
||||
var sessionCount = SessionCount(source);
|
||||
var gross = source.VariantSnapshot.Price * sessionCount;
|
||||
var (commission, payout) = BookingAmounts.Split(gross, rate);
|
||||
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = source.RequestId,
|
||||
CustomerId = source.CustomerId,
|
||||
NurseId = source.NurseId,
|
||||
PatientId = source.PatientId,
|
||||
VariantId = source.VariantId,
|
||||
CustomerAddressId = source.CustomerAddressId,
|
||||
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
|
||||
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
|
||||
GrossPriceIrr = gross,
|
||||
BalinyaarCommissionIrr = commission,
|
||||
PlatformFeeRate = rate,
|
||||
NursePayoutAmount = payout,
|
||||
PspFeeAmount = pspFeeAmount,
|
||||
SessionCount = (short)sessionCount,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd
|
||||
};
|
||||
|
||||
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
|
||||
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
|
||||
for (var i = 0; i < sessionCount; i++)
|
||||
{
|
||||
booking.Sessions.Add(new BookingSession
|
||||
{
|
||||
SessionIndex = i + 1,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd,
|
||||
VisitPayoutAmount = visitPayouts[i]
|
||||
});
|
||||
}
|
||||
|
||||
booking.TransitionTo(BookingStatus.Confirmed, now);
|
||||
return booking;
|
||||
}
|
||||
|
||||
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
|
||||
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
|
||||
private static readonly JsonSerializerOptions AddressJson = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
|
||||
};
|
||||
|
||||
public static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
|
||||
{
|
||||
addressId = a.AddressId,
|
||||
title = a.Title,
|
||||
cityId = a.CityId,
|
||||
cityNameFa = a.CityNameFa,
|
||||
cityNameEn = a.CityNameEn,
|
||||
districtId = a.DistrictId,
|
||||
districtNameFa = a.DistrictNameFa,
|
||||
districtNameEn = a.DistrictNameEn,
|
||||
addressLine = a.AddressLine,
|
||||
postalCode = a.PostalCode,
|
||||
recipientName = a.RecipientName,
|
||||
recipientPhone = a.RecipientPhone,
|
||||
latitude = a.Latitude,
|
||||
longitude = a.Longitude
|
||||
}, AddressJson);
|
||||
}
|
||||
+2
-71
@@ -1,7 +1,5 @@
|
||||
#nullable enable
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Unicode;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
@@ -9,9 +7,6 @@ using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Mediator;
|
||||
// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when
|
||||
// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate.
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
|
||||
|
||||
@@ -60,49 +55,10 @@ internal sealed class ConvertRequestToBookingCommandHandler(
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// session_count comes from the variant (a single visit is 1); gross = price × sessions.
|
||||
var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
|
||||
var gross = source.VariantSnapshot.Price * sessionCount;
|
||||
|
||||
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
var (commission, payout) = BookingAmounts.Split(gross, rate);
|
||||
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = source.RequestId,
|
||||
CustomerId = source.CustomerId,
|
||||
NurseId = source.NurseId,
|
||||
PatientId = source.PatientId,
|
||||
VariantId = source.VariantId,
|
||||
CustomerAddressId = source.CustomerAddressId,
|
||||
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
|
||||
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
|
||||
GrossPriceIrr = gross,
|
||||
BalinyaarCommissionIrr = commission,
|
||||
PlatformFeeRate = rate,
|
||||
NursePayoutAmount = payout,
|
||||
PspFeeAmount = capture.PspFeeAmount,
|
||||
SessionCount = (short)sessionCount,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd
|
||||
};
|
||||
|
||||
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
|
||||
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
|
||||
for (var i = 0; i < sessionCount; i++)
|
||||
{
|
||||
booking.Sessions.Add(new BookingSession
|
||||
{
|
||||
SessionIndex = i + 1,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd,
|
||||
VisitPayoutAmount = visitPayouts[i]
|
||||
});
|
||||
}
|
||||
|
||||
booking.TransitionTo(BookingStatus.Confirmed, now);
|
||||
// The conversion/amount logic is shared with b10's real card capture — build through BookingFactory.
|
||||
var booking = BookingFactory.Create(source, rate, now, capture.PspFeeAmount, variantSnapshotSerializer);
|
||||
|
||||
// Flip the request → converted in the same unit of work. Re-check the tracked state so a racing
|
||||
// cancel/expiry that already moved it is a clean conflict, not a double conversion.
|
||||
@@ -136,29 +92,4 @@ internal sealed class ConvertRequestToBookingCommandHandler(
|
||||
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
|
||||
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
|
||||
}
|
||||
|
||||
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
|
||||
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
|
||||
private static readonly JsonSerializerOptions AddressJson = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
|
||||
};
|
||||
|
||||
private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
|
||||
{
|
||||
addressId = a.AddressId,
|
||||
title = a.Title,
|
||||
cityId = a.CityId,
|
||||
cityNameFa = a.CityNameFa,
|
||||
cityNameEn = a.CityNameEn,
|
||||
districtId = a.DistrictId,
|
||||
districtNameFa = a.DistrictNameFa,
|
||||
districtNameEn = a.DistrictNameEn,
|
||||
addressLine = a.AddressLine,
|
||||
postalCode = a.PostalCode,
|
||||
recipientName = a.RecipientName,
|
||||
recipientPhone = a.RecipientPhone,
|
||||
latitude = a.Latitude,
|
||||
longitude = a.Longitude
|
||||
}, AddressJson);
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
|
||||
internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
IPaymentProvider paymentProvider,
|
||||
ISettlementSplitProvider settlementSplitProvider,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ConfirmPaymentAndPostLedgerCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(ConfirmPaymentAndPostLedgerCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(request.PaymentTransactionId, cancellationToken);
|
||||
if (transaction is null)
|
||||
return OperationResult<bool>.NotFoundResult("Payment transaction not found.");
|
||||
|
||||
// Already captured — idempotent no-op success (a replayed confirm must not re-post).
|
||||
if (transaction.Status == PaymentTransactionStatus.Succeeded)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Never trust a callback alone — re-verify amount + reference with the gateway before confirming.
|
||||
var verify = await paymentProvider.VerifyAsync(transaction.GatewayReferenceCode ?? string.Empty, transaction.Amount, cancellationToken);
|
||||
if (verify.Status != PaymentProviderStatus.Succeeded || verify.AmountIrr != transaction.Amount)
|
||||
{
|
||||
transaction.MarkFailed(verify.Status.ToString(), null);
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.FailureResult("The payment could not be verified with the gateway.");
|
||||
}
|
||||
|
||||
// Create/confirm the booking through the shared b9 conversion (idempotent on UNIQUE booking_request_id).
|
||||
var (bookingId, amounts, created, participants) = await EnsureBookingAsync(transaction.BookingRequestId, now, cancellationToken);
|
||||
if (bookingId is not { } booking)
|
||||
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
|
||||
|
||||
transaction.MarkSucceeded(booking, verify.Status.ToString(), transaction.GatewayResponseJson);
|
||||
|
||||
// Idempotent ledger: don't post a second capture group if one already exists for this transaction.
|
||||
if (!await unitOfWork.PaymentRepository.LedgerGroupExistsForTransactionAsync(transaction.Id, cancellationToken))
|
||||
{
|
||||
var legs = LedgerPosting.CardCapture(
|
||||
booking, amounts!.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr, transaction.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent confirm for a different transaction of the same booking hit the filtered
|
||||
// UNIQUE(booking_id) WHERE status='succeeded' — the DB backstop. Treat as already-captured.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
// The lawful تسهیم split to the registered IBANs (the provider credits each directly; the platform
|
||||
// never moves money). The mock records the intent; a real adapter resolves each beneficiary's SHEBA.
|
||||
await settlementSplitProvider.RegisterSplitAsync(
|
||||
booking,
|
||||
[
|
||||
new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"),
|
||||
new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform")
|
||||
],
|
||||
cancellationToken);
|
||||
|
||||
if (created && participants is { } p)
|
||||
await NotifyConfirmedAsync(p, booking, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
private async Task<(long? BookingId, BookingLedgerAmountsLocal? Amounts, bool Created, BookingParticipantsLocal? Participants)>
|
||||
EnsureBookingAsync(long bookingRequestId, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
|
||||
if (existingId is { } eb)
|
||||
{
|
||||
var amounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
|
||||
return amounts is null
|
||||
? (null, null, false, null)
|
||||
: (eb, new BookingLedgerAmountsLocal(amounts.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr), false, null);
|
||||
}
|
||||
|
||||
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
|
||||
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return (null, null, false, null);
|
||||
|
||||
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
|
||||
|
||||
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
|
||||
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
|
||||
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
|
||||
return (null, null, false, null);
|
||||
|
||||
trackedRequest.MarkConverted();
|
||||
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return (
|
||||
booking.Id,
|
||||
new BookingLedgerAmountsLocal(booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount),
|
||||
true,
|
||||
new BookingParticipantsLocal(source.CustomerUserId, source.NurseUserId));
|
||||
}
|
||||
|
||||
private async Task NotifyConfirmedAsync(BookingParticipantsLocal p, long bookingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed",
|
||||
"Your payment was captured and your booking is confirmed.", payload),
|
||||
cancellationToken);
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(p.NurseUserId, "booking_confirmed_nurse", "New confirmed booking",
|
||||
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record BookingLedgerAmountsLocal(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
|
||||
|
||||
private sealed record BookingParticipantsLocal(int CustomerUserId, int NurseUserId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
|
||||
/// <summary>
|
||||
/// Captures a verified payment: server-side re-verifies the attempt, creates/confirms the booking (through the
|
||||
/// shared b9 conversion), posts the <b>balanced card-capture ledger group</b> (DEBIT <c>escrow_held</c> gross =
|
||||
/// CREDIT <c>platform_revenue</c> commission + <c>nurse_payable</c> payout), and registers the تسهیم split.
|
||||
/// It is <b>never a public endpoint</b> — dispatched only by <c>HandlePaymentWebhook</c> (and directly in
|
||||
/// tests). Idempotent: an already-succeeded transaction, or a concurrent double-confirm caught by the filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c>, is a no-op success.
|
||||
/// </summary>
|
||||
public record ConfirmPaymentAndPostLedgerCommand(long PaymentTransactionId) : IRequest<OperationResult<bool>>;
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
|
||||
internal sealed class HandlePaymentWebhookCommandHandler(
|
||||
ISender sender,
|
||||
IUnitOfWork unitOfWork,
|
||||
IWebhookVerifier webhookVerifier,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<HandlePaymentWebhookCommand, OperationResult<WebhookIngestResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<WebhookIngestResult>> Handle(HandlePaymentWebhookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var headers = request.Headers ?? new Dictionary<string, string>();
|
||||
var rawBody = request.RawBody ?? string.Empty;
|
||||
var verification = webhookVerifier.Verify(request.Provider, headers, rawBody);
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Dedup FIRST on the idempotency key: a duplicate replay never mutates money state again.
|
||||
if (!string.IsNullOrEmpty(verification.ExternalEventId))
|
||||
{
|
||||
var duplicate = await unitOfWork.PaymentRepository.GetWebhookEventByKeyAsync(request.Provider, verification.ExternalEventId, cancellationToken);
|
||||
if (duplicate is not null)
|
||||
return Success(duplicate.ProcessingStatus, isDuplicate: true);
|
||||
}
|
||||
|
||||
var webhookEvent = new PaymentWebhookEvent
|
||||
{
|
||||
ProviderCode = request.Provider,
|
||||
ExternalEventId = verification.ExternalEventId,
|
||||
EventType = verification.EventType,
|
||||
SignatureValid = verification.SignatureValid,
|
||||
PayloadJson = rawBody,
|
||||
ReceivedAt = now
|
||||
};
|
||||
|
||||
// An unverified-signature callback mutates nothing — stored ignored and stopped.
|
||||
if (!verification.SignatureValid)
|
||||
{
|
||||
webhookEvent.MarkIgnored(now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
// A non-success event (or one with no reference) has nothing to confirm — acknowledged, no money moves.
|
||||
if (!verification.IsSuccessEvent || string.IsNullOrEmpty(verification.GatewayReferenceCode))
|
||||
{
|
||||
webhookEvent.MarkProcessed(null, now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(verification.GatewayReferenceCode!, cancellationToken);
|
||||
if (transaction is null)
|
||||
{
|
||||
// No matching pending attempt — retryable (failed), never a silent success.
|
||||
webhookEvent.MarkFailed(now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
// The whole money mutation runs under the lock; the DB uniques remain the authoritative backstop if
|
||||
// the lock is lost/expired. Keyed on the request (a b9 booking exists only after this confirm).
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking-request:{transaction.BookingRequestId}:payment", cancellationToken);
|
||||
|
||||
// Claim the idempotency key first (inside the same context that mutates payment state). A racing
|
||||
// duplicate insert loses on the unique index and is treated as a no-op replay.
|
||||
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await unitOfWork.RollBackAsync();
|
||||
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
|
||||
}
|
||||
|
||||
// Re-verify server-side + capture + post the balanced ledger group + confirm the booking.
|
||||
var confirm = await sender.Send(new ConfirmPaymentAndPostLedgerCommand(transaction.Id), cancellationToken);
|
||||
|
||||
if (confirm.IsSuccess)
|
||||
webhookEvent.MarkProcessed(transaction.Id, now);
|
||||
else
|
||||
webhookEvent.MarkFailed(now);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private async Task<OperationResult<WebhookIngestResult>> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent insert of the same (provider, external_event_id) — the idempotency backstop.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: true);
|
||||
}
|
||||
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private static OperationResult<WebhookIngestResult> Success(string processingStatus, bool isDuplicate)
|
||||
=> OperationResult<WebhookIngestResult>.SuccessResult(new WebhookIngestResult(processingStatus, isDuplicate));
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
|
||||
/// <summary>
|
||||
/// The verify-then-dedup-then-mutate ingest for every inbound PSP callback. It verifies the signature, upserts
|
||||
/// <c>payment_webhook_events</c> <b>first</b> on <c>(provider_code, external_event_id)</c> — no-op on a
|
||||
/// duplicate replay — and, on a new success event, re-verifies server-side and dispatches
|
||||
/// <c>ConfirmPaymentAndPostLedger</c>, all under a Redis <c>lock(booking:{id}:payment)</c> with the DB uniques
|
||||
/// as the authoritative backstop. Authenticated by signature, not a user session; at-least-once tolerant.
|
||||
/// </summary>
|
||||
/// <param name="Provider">The provider code from the route (<c>zarinpal</c>/<c>sadad</c>/…).</param>
|
||||
/// <param name="Headers">The raw callback headers (signature material for the verifier).</param>
|
||||
/// <param name="RawBody">The raw callback body — stored verbatim in <c>payload_json</c>.</param>
|
||||
public record HandlePaymentWebhookCommand(string Provider, IReadOnlyDictionary<string, string> Headers, string RawBody)
|
||||
: IRequest<OperationResult<WebhookIngestResult>>;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
internal sealed class InitiatePaymentCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPaymentProvider paymentProvider,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<InitiatePaymentCommand, OperationResult<InitiatePaymentResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<InitiatePaymentResult>> Handle(InitiatePaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<InitiatePaymentResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is null)
|
||||
return OperationResult<InitiatePaymentResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
// Tenancy: only the owning customer may pay; any other caller must not learn the request exists.
|
||||
var ctx = await unitOfWork.BookingRequestRepository.GetPaymentContextAsync(request.BookingRequestId, cancellationToken);
|
||||
if (ctx is null || ctx.CustomerId != customerId)
|
||||
return OperationResult<InitiatePaymentResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
// Already paid — do not open a second attempt. The filtered succeeded-unique is the structural backstop.
|
||||
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("This booking has already been paid.");
|
||||
|
||||
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("This request is not awaiting payment.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
if (ctx.PaymentDeadlineAt is { } deadline && deadline < now)
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("The payment window for this request has lapsed.");
|
||||
|
||||
// Config-driven selection: the active standard gateway with the lowest priority (swap a cut-off
|
||||
// provider by config, not a code change).
|
||||
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Standard, cancellationToken);
|
||||
if (gatewayId is null)
|
||||
return OperationResult<InitiatePaymentResult>.FailureResult("No active payment gateway is configured.");
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
|
||||
? $"br-{request.BookingRequestId}"
|
||||
: request.IdempotencyKey!;
|
||||
|
||||
// amount is the request's frozen gross — never recomputed from client input.
|
||||
var init = await paymentProvider.InitPaymentAsync(request.BookingRequestId, ctx.GrossIrr, idempotencyKey, cancellationToken);
|
||||
|
||||
// A retried initiate (same idempotency key ⇒ same reference) reuses the existing pending attempt
|
||||
// instead of colliding on the filtered UNIQUE(gateway_reference_code).
|
||||
var existing = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(init.GatewayReferenceCode, cancellationToken);
|
||||
if (existing is not null)
|
||||
return OperationResult<InitiatePaymentResult>.SuccessResult(
|
||||
new InitiatePaymentResult(existing.Id, init.RedirectUrl, init.GatewayReferenceCode));
|
||||
|
||||
var transaction = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = request.BookingRequestId,
|
||||
CustomerId = customerId.Value,
|
||||
GatewayId = gatewayId.Value,
|
||||
Amount = ctx.GrossIrr,
|
||||
Currency = "IRR",
|
||||
GatewayReferenceCode = init.GatewayReferenceCode,
|
||||
IpAddress = currentUser.IpAddress
|
||||
};
|
||||
|
||||
await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<InitiatePaymentResult>.SuccessResult(
|
||||
new InitiatePaymentResult(transaction.Id, init.RedirectUrl, init.GatewayReferenceCode));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
public sealed class InitiatePaymentCommandValidator : AbstractValidator<InitiatePaymentCommand>
|
||||
{
|
||||
public InitiatePaymentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingRequestId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a card payment for an <c>accepted_awaiting_payment</c> booking request owned by the caller: selects
|
||||
/// the active <c>standard</c> gateway, asks the PSP to open an IPG session, and persists a <c>pending</c>
|
||||
/// <c>payment_transactions</c> row (with the gateway reference, honouring its filtered unique). The charged
|
||||
/// amount is the request's frozen gross (variant price × session count) — never client-supplied. A repeat for
|
||||
/// a request already captured is a <c>409</c>: the filtered <c>UNIQUE(booking_id) WHERE status='succeeded'</c>
|
||||
/// is the structural backstop.
|
||||
/// </summary>
|
||||
/// <param name="BookingRequestId">The accepted request to pay for (a b9 <c>bookings</c> row exists only on capture).</param>
|
||||
/// <param name="IdempotencyKey">The client's idempotency key, so a retried initiate reuses the same reference.</param>
|
||||
public record InitiatePaymentCommand(long BookingRequestId, string? IdempotencyKey)
|
||||
: IRequest<OperationResult<InitiatePaymentResult>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
|
||||
internal sealed class GetNursePayableBalanceQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetNursePayableBalanceQuery, OperationResult<NursePayableBalanceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NursePayableBalanceDto>> Handle(GetNursePayableBalanceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NursePayableBalanceDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
// The nurse themself, or an admin/finance role — no one else may read a nurse's payable balance.
|
||||
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
|
||||
if (!isAdmin)
|
||||
{
|
||||
var callerNurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (callerNurseId is null || callerNurseId != request.NurseId)
|
||||
return OperationResult<NursePayableBalanceDto>.ForbiddenResult("You may only read your own payable balance.");
|
||||
}
|
||||
|
||||
var balance = await unitOfWork.PaymentRepository.GetNursePayableBalanceAsync(request.NurseId, cancellationToken);
|
||||
return OperationResult<NursePayableBalanceDto>.SuccessResult(
|
||||
new NursePayableBalanceDto(request.NurseId, balance.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
|
||||
/// <summary>
|
||||
/// The IRR balance currently owed a nurse — the signed sum of <c>nurse_payable</c> ledger legs (credit adds,
|
||||
/// debit subtracts). A <b>pure projection over the append-only ledger</b> (no cached wallet column ever); this
|
||||
/// is what b13 payouts read to know what to pay. Authorized to the nurse themself or an admin/finance role.
|
||||
/// </summary>
|
||||
public record GetNursePayableBalanceQuery(long NurseId) : IRequest<OperationResult<NursePayableBalanceDto>>;
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Baya.Application.Models.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The minimal facts <c>InitiatePayment</c> needs to validate a card attempt against an
|
||||
/// <c>accepted_awaiting_payment</c> request: the owning customer (tenancy), the request status, the frozen
|
||||
/// payment window, and the gross to charge (variant price × session count — the same figure b9 freezes onto
|
||||
/// the booking on capture).
|
||||
/// </summary>
|
||||
public record BookingPaymentContext(long RequestId, long CustomerId, string Status, System.DateTime? PaymentDeadlineAt, long GrossIrr);
|
||||
|
||||
/// <summary>The three frozen amounts + nurse a card-capture ledger group posts against, read from an existing
|
||||
/// booking (the confirm path is idempotent — a booking created by a prior confirm reuses this).</summary>
|
||||
public record BookingLedgerAmounts(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
|
||||
|
||||
/// <summary>
|
||||
/// The result of starting a card payment: the redirect the client sends the payer to, plus the ids that
|
||||
/// identify the attempt. Money never appears here — it is the booking's frozen gross, echoed nowhere the
|
||||
/// customer could tamper with it.
|
||||
/// </summary>
|
||||
/// <param name="TransactionId">The <c>payment_transactions</c> row id for this attempt.</param>
|
||||
/// <param name="RedirectUrl">Where to send the payer to complete the card payment.</param>
|
||||
/// <param name="GatewayReferenceCode">The deterministic gateway reference persisted on the attempt.</param>
|
||||
public record InitiatePaymentResult(long TransactionId, string RedirectUrl, string GatewayReferenceCode);
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a webhook ingest — the terminal <c>processing_status</c> and whether this call was a
|
||||
/// duplicate replay that was short-circuited. The endpoint always returns success (at-least-once tolerant).
|
||||
/// </summary>
|
||||
/// <param name="ProcessingStatus">A <c>payment_webhook_events.processing_status</c> code.</param>
|
||||
/// <param name="Duplicate">True when the idempotency key was already stored — no money state was touched.</param>
|
||||
public record WebhookIngestResult(string ProcessingStatus, bool Duplicate);
|
||||
|
||||
/// <summary>
|
||||
/// The IRR balance currently owed a nurse, <b>derived from the ledger</b> (signed by direction), never a
|
||||
/// stored column. Serialized as a string of digits per the money convention.
|
||||
/// </summary>
|
||||
/// <param name="NurseId">The nurse profile id.</param>
|
||||
/// <param name="BalanceIrr">The signed <c>nurse_payable</c> sum, as a digit string.</param>
|
||||
public record NursePayableBalanceDto(long NurseId, string BalanceIrr);
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed set of double-entry <c>ledger_entries.account_type</c> codes — the accounts every money event
|
||||
/// posts against. This phase only posts the first three (the card-capture group), but <b>all</b> are defined
|
||||
/// now because b11 (refunds/clawbacks) and b12 (BNPL) post against the rest. Balances are <b>derived by
|
||||
/// filtering</b> on these, never stored in a drifting column.
|
||||
/// </summary>
|
||||
public static class LedgerAccountType
|
||||
{
|
||||
/// <summary>Funds held in escrow state (legally at the PSP/bank, never platform cash). Debited on capture.</summary>
|
||||
public const string EscrowHeld = "escrow_held";
|
||||
|
||||
/// <summary>Balinyaar's own commission revenue. Credited on capture.</summary>
|
||||
public const string PlatformRevenue = "platform_revenue";
|
||||
|
||||
/// <summary>Amount owed to a nurse (carries <c>nurse_id</c>). Credited on capture; b13 pays it down.</summary>
|
||||
public const string NursePayable = "nurse_payable";
|
||||
|
||||
/// <summary>Amount owed back to a customer for a refund. Posted by b11.</summary>
|
||||
public const string RefundPayable = "refund_payable";
|
||||
|
||||
/// <summary>The BNPL provider's commission expense. Posted by b12's settle group.</summary>
|
||||
public const string BnplFeeExpense = "bnpl_fee_expense";
|
||||
|
||||
/// <summary>The PSP/gateway fee expense on a capture (true margin). Reserved.</summary>
|
||||
public const string PspFeeExpense = "psp_fee_expense";
|
||||
|
||||
/// <summary>A receivable from a nurse already paid when a booking is later refunded (carries <c>nurse_id</c>).
|
||||
/// Posted by b11.</summary>
|
||||
public const string NurseClawbackReceivable = "nurse_clawback_receivable";
|
||||
|
||||
/// <summary>Written-off uncollectable amount. Reserved.</summary>
|
||||
public const string BadDebt = "bad_debt";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The two <c>ledger_entries.direction</c> codes. <c>amount_irr</c> is <b>always positive</b>; the direction
|
||||
/// carries the sign. A balanced posting group has Σ(debit) = Σ(credit).
|
||||
/// </summary>
|
||||
public static class LedgerDirection
|
||||
{
|
||||
public const string Debit = "debit";
|
||||
public const string Credit = "credit";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ledger_entries.source_ref_type</c> codes — what a posting group's <c>source_ref_id</c> points at.
|
||||
/// </summary>
|
||||
public static class LedgerSourceRefType
|
||||
{
|
||||
public const string PaymentTransaction = "payment_transaction";
|
||||
public const string Refund = "refund";
|
||||
public const string NursePayout = "nurse_payout";
|
||||
public const string BnplTransaction = "bnpl_transaction";
|
||||
public const string Clawback = "clawback";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// One leg of the append-only, double-entry financial <b>source of truth</b>. Every money event posts
|
||||
/// <b>balanced</b> legs sharing a <see cref="TransactionGroupId"/> (Σ debit = Σ credit per group);
|
||||
/// <see cref="AmountIrr"/> is always positive and <see cref="Direction"/> carries the sign. Per-nurse balances
|
||||
/// (b13) derive by filtering <c>account_type = 'nurse_payable' AND nurse_id = …</c> — never a stored column.
|
||||
/// <para>
|
||||
/// This entity is <b>insert-only</b>: it implements <see cref="IEntity"/> but <b>not</b>
|
||||
/// <see cref="ITimeModification"/>/<see cref="IAuditableEntity"/>, so the audit interceptor never stamps a
|
||||
/// modify on it and there is no soft-delete path. Corrections are <b>new balancing rows</b>, never edits.
|
||||
/// <see cref="CreatedAt"/> is set explicitly from <c>IDateTimeProvider</c> at post time.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class LedgerEntry : IEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Groups the balanced legs of one money event.</summary>
|
||||
public Guid TransactionGroupId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerAccountType"/> code.</summary>
|
||||
public string AccountType { get; set; } = null!;
|
||||
|
||||
/// <summary>Set for <c>nurse_payable</c>/<c>nurse_clawback_receivable</c> legs; null otherwise.</summary>
|
||||
public long? NurseId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerDirection"/> code — carries the sign of <see cref="AmountIrr"/>.</summary>
|
||||
public string Direction { get; set; } = null!;
|
||||
|
||||
/// <summary>Always positive IRR; the sign lives in <see cref="Direction"/>. No floats.</summary>
|
||||
public long AmountIrr { get; set; }
|
||||
|
||||
public long? BookingId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerSourceRefType"/> code.</summary>
|
||||
public string SourceRefType { get; set; } = null!;
|
||||
|
||||
public long SourceRefId { get; set; }
|
||||
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>Append-only; never updated. Set from <c>IDateTimeProvider</c> at post time.</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical, <b>balanced</b> ledger posting groups so the posting discipline lives in one
|
||||
/// unit-testable place instead of a handler. Every group returned satisfies Σ(debit) = Σ(credit).
|
||||
/// </summary>
|
||||
public static class LedgerPosting
|
||||
{
|
||||
/// <summary>
|
||||
/// The <b>card-capture group</b>: <c>DEBIT escrow_held gross = CREDIT platform_revenue commission +
|
||||
/// nurse_payable payout</c>, all under one fresh <see cref="LedgerEntry.TransactionGroupId"/>. The three
|
||||
/// amounts come <b>frozen from the booking</b> (b9) — never recomputed here — and must reconcile
|
||||
/// (<c>gross = commission + payout</c>); this method throws if they do not, so an unbalanced group can
|
||||
/// never be persisted.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> CardCapture(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long grossIrr,
|
||||
long commissionIrr,
|
||||
long payoutIrr,
|
||||
long paymentTransactionId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
if (grossIrr != commissionIrr + payoutIrr)
|
||||
throw new InvalidOperationException(
|
||||
$"Card-capture group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
|
||||
LedgerEntry Leg(string account, string direction, long amount, long? nurse) => new()
|
||||
{
|
||||
TransactionGroupId = group,
|
||||
AccountType = account,
|
||||
Direction = direction,
|
||||
AmountIrr = amount,
|
||||
NurseId = nurse,
|
||||
BookingId = bookingId,
|
||||
SourceRefType = LedgerSourceRefType.PaymentTransaction,
|
||||
SourceRefId = paymentTransactionId,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
Leg(LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null),
|
||||
Leg(LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null),
|
||||
Leg(LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Config per connected PSP/BNPL provider — the unit of <b>selection and failover</b>. The active
|
||||
/// <c>standard</c> gateway with the lowest <see cref="Priority"/> is chosen for a card capture, so a
|
||||
/// cut-off provider (Toman/Jibit were abruptly suspended Nov 2024) is swapped <b>by config, not a code
|
||||
/// change</b>. <see cref="ConfigJson"/> holds provider-selection/failover config (merchant id, terminal/IBAN
|
||||
/// registration for the تسهیم split, base url, sandbox flag) — <b>never per-transaction credentials</b> — and
|
||||
/// is <b>encrypted at rest</b> through the field encryptor and never logged in plaintext.
|
||||
/// </summary>
|
||||
public class PaymentGateway : BaseEntity<long>
|
||||
{
|
||||
/// <summary>Provider code — <c>zarinpal</c> / <c>sadad</c> / <c>vandar</c> / <c>jibit</c> …</summary>
|
||||
public string ProviderCode { get; set; } = null!;
|
||||
|
||||
/// <summary>A <see cref="PaymentGatewayType"/> code — selects the card (<c>standard</c>) vs BNPL flow.</summary>
|
||||
public string Type { get; set; } = PaymentGatewayType.Standard;
|
||||
|
||||
public string DisplayName { get; set; } = null!;
|
||||
|
||||
/// <summary>Encrypted provider-selection/failover config (merchant id, terminal/IBAN registration, base
|
||||
/// url, sandbox flag). Encrypted at rest via <c>IFieldEncryptor</c>; never per-transaction credentials.</summary>
|
||||
public string ConfigJson { get; set; } = null!;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Failover order — the active gateway of a type with the lowest priority wins selection.</summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_gateways.type</c> code set — it <b>selects the flow</b>. A <c>standard</c> gateway
|
||||
/// is a card IPG (Shaparak-routed); a <c>bnpl</c> gateway is a Buy-Now-Pay-Later provider (b12). Persisted
|
||||
/// as these stable snake_case codes, never a C# enum member name.
|
||||
/// </summary>
|
||||
public static class PaymentGatewayType
|
||||
{
|
||||
/// <summary>Card IPG — the rail this phase drives end-to-end.</summary>
|
||||
public const string Standard = "standard";
|
||||
|
||||
/// <summary>Buy-Now-Pay-Later provider — settle flow is DEFERRED to b12.</summary>
|
||||
public const string Bnpl = "bnpl";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Every payment attempt against a booking request; the <see cref="PaymentTransactionStatus.Succeeded"/> row
|
||||
/// is what triggers confirmation. It stores the full <see cref="GatewayResponseJson"/> and the Shaparak
|
||||
/// <see cref="GatewayReferenceCode"/> — the definitive proof for reconciliation and chargebacks.
|
||||
/// <para>
|
||||
/// Two structural idempotency guards protect the money path (configured on the table, not just in a handler):
|
||||
/// a filtered <c>UNIQUE(gateway_reference_code) WHERE NOT NULL</c> (Shaparak ref dedupe) and a filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c> (<b>at most one capturing transaction per booking</b>).
|
||||
/// The latter is the authoritative anti-double-capture backstop even if the Redis lock is lost.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because a b9 <c>bookings</c> row is created only <i>on capture</i>, the attempt is opened against the
|
||||
/// originating <see cref="BookingRequestId"/> and <see cref="BookingId"/> stays null until confirmation binds
|
||||
/// the two together (which is also when the succeeded-unique starts guarding the booking).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PaymentTransaction : BaseEntity<long>
|
||||
{
|
||||
/// <summary>The originating <c>accepted_awaiting_payment</c> request this attempt pays for.</summary>
|
||||
public long BookingRequestId { get; set; }
|
||||
|
||||
/// <summary>The confirmed booking, set only when this attempt succeeds and conversion creates it. Null
|
||||
/// while pending — so the filtered succeeded-unique only ever guards a captured booking.</summary>
|
||||
public long? BookingId { get; private set; }
|
||||
|
||||
public long CustomerId { get; set; }
|
||||
public long GatewayId { get; set; }
|
||||
|
||||
/// <summary>The charged amount (IRR) — the booking's frozen gross. No floats, ever.</summary>
|
||||
public long Amount { get; set; }
|
||||
|
||||
/// <summary>Always <c>IRR</c> internally; Toman is a display concern at the provider boundary only.</summary>
|
||||
public string Currency { get; set; } = "IRR";
|
||||
|
||||
/// <summary>Guarded — mutated only through <see cref="MarkSucceeded"/>/<see cref="MarkFailed"/>.</summary>
|
||||
public string Status { get; private set; } = PaymentTransactionStatus.Pending;
|
||||
|
||||
public string? GatewayTransactionId { get; set; }
|
||||
|
||||
/// <summary>The Shaparak reference code — definitive reconciliation/chargeback proof. Filtered-unique.</summary>
|
||||
public string? GatewayReferenceCode { get; set; }
|
||||
|
||||
public string? GatewayResponseCode { get; private set; }
|
||||
public string? GatewayResponseJson { get; private set; }
|
||||
|
||||
public bool IsInstallment { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>Binds the succeeded capture to its booking and records the gateway response. The filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c> makes a second succeeded row for the same booking
|
||||
/// impossible — a concurrent double-confirm fails on the constraint (treated as an idempotent no-op).</summary>
|
||||
public void MarkSucceeded(long bookingId, string? responseCode, string? responseJson)
|
||||
{
|
||||
BookingId = bookingId;
|
||||
Status = PaymentTransactionStatus.Succeeded;
|
||||
GatewayResponseCode = responseCode;
|
||||
GatewayResponseJson = responseJson;
|
||||
}
|
||||
|
||||
public void MarkFailed(string? responseCode, string? responseJson)
|
||||
{
|
||||
Status = PaymentTransactionStatus.Failed;
|
||||
GatewayResponseCode = responseCode;
|
||||
GatewayResponseJson = responseJson;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_transactions.status</c> code set. Exactly one <see cref="Succeeded"/> row may exist
|
||||
/// per booking — enforced structurally by the filtered <c>UNIQUE(booking_id) WHERE status='succeeded'</c>,
|
||||
/// the authoritative anti-double-capture backstop. Persisted as these stable snake_case codes.
|
||||
/// </summary>
|
||||
public static class PaymentTransactionStatus
|
||||
{
|
||||
/// <summary>An IPG session was started; awaiting the PSP callback.</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>Capture confirmed (server-side re-verified) — triggers the ledger posting + booking confirm.</summary>
|
||||
public const string Succeeded = "succeeded";
|
||||
|
||||
/// <summary>The attempt failed at the gateway; a fresh attempt is a new row.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The raw, deduplicated store of every inbound PSP/BNPL callback — the <b>idempotency chokepoint</b> of the
|
||||
/// whole money path. PSP callbacks are at-least-once and retried, so the handler <b>upserts here first</b>,
|
||||
/// keyed on <c>UNIQUE(provider_code, external_event_id)</c>, and <b>no-ops on a duplicate</b> inside the same
|
||||
/// transaction that would mutate payment state: a replayed <c>succeeded</c> can never double-confirm and a
|
||||
/// replayed <c>settled</c> can never double-count.
|
||||
/// </summary>
|
||||
public class PaymentWebhookEvent : BaseEntity<long>
|
||||
{
|
||||
public string ProviderCode { get; set; } = null!;
|
||||
|
||||
/// <summary>The provider's own event id — the second half of the idempotency key.</summary>
|
||||
public string ExternalEventId { get; set; } = null!;
|
||||
|
||||
public string EventType { get; set; } = null!;
|
||||
|
||||
/// <summary>Whether the signature verified. A false here forces <see cref="WebhookProcessingStatus.Ignored"/>
|
||||
/// and stops the path before any money moves.</summary>
|
||||
public bool SignatureValid { get; set; }
|
||||
|
||||
public string PayloadJson { get; set; } = null!;
|
||||
|
||||
/// <summary>Guarded — mutated only through the mark-* methods.</summary>
|
||||
public string ProcessingStatus { get; private set; } = WebhookProcessingStatus.Received;
|
||||
|
||||
public long? RelatedPaymentTransactionId { get; private set; }
|
||||
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
public void MarkProcessed(long? relatedPaymentTransactionId, DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Processed;
|
||||
RelatedPaymentTransactionId = relatedPaymentTransactionId;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
|
||||
public void MarkIgnored(DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Ignored;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
|
||||
public void MarkFailed(DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Failed;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_webhook_events.processing_status</c> code set. Persisted as these stable snake_case
|
||||
/// codes.
|
||||
/// </summary>
|
||||
public static class WebhookProcessingStatus
|
||||
{
|
||||
/// <summary>Stored, not yet acted on (the transient state a brand-new event is inserted in).</summary>
|
||||
public const string Received = "received";
|
||||
|
||||
/// <summary>Verified, deduplicated and applied — money state was mutated by this event.</summary>
|
||||
public const string Processed = "processed";
|
||||
|
||||
/// <summary>Verified but the downstream mutation failed; safe to retry.</summary>
|
||||
public const string Failed = "failed";
|
||||
|
||||
/// <summary>Rejected before any money moved — an invalid signature or a nothing-to-do event.</summary>
|
||||
public const string Ignored = "ignored";
|
||||
}
|
||||
Reference in New Issue
Block a user