frontend phase 5 & backend phase 12
This commit is contained in:
@@ -2,25 +2,90 @@
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). <b>b12 owns the real, full definition of this
|
||||
/// seam</b>; b11 introduces this minimal shape (revert/update) plus a thin local mock so the <c>bnpl_revert</c>
|
||||
/// refund path is exercised before b12 merges. Money <b>always</b> flows <c>customer ↔ provider ↔ Balinyaar</c>
|
||||
/// — never nurse→customer or Balinyaar→customer direct. A <b>full</b> reversal is <see cref="RevertAsync"/>; a
|
||||
/// <b>partial/shortened</b> one is <see cref="UpdateAsync"/> with a strictly-lower amount. Every amount is IRR
|
||||
/// <c>long</c>; the <paramref name="idempotencyKey"/> makes a retried revert a no-op rather than a double refund.
|
||||
/// The Buy-Now-Pay-Later provider seam (SnappPay / Digipay / Tara / Torob Pay) — the SnappPay-superset verb set
|
||||
/// that drives the full <c>eligible → token_issued → verified → settled → reverted/cancelled</c> state machine.
|
||||
/// <b>One impl per <c>provider_code</c></b>, selected through <see cref="IBnplProviderResolver"/>. A BNPL order
|
||||
/// is, in our books, a card payment landing net-of-fee: the <b>settle</b> returns the net amount + the provider's
|
||||
/// merchant commission (read from the actual settlement, never hardcoded) + a <b>per-transaction</b>
|
||||
/// <c>settled_at</c> that is never assumed instant. Money <b>always</b> flows <c>customer ↔ provider ↔
|
||||
/// Balinyaar</c>; every amount is IRR <c>long</c> (Toman is converted only in a real adapter's boundary via
|
||||
/// <see cref="ICurrencyNormalizer"/>); an <c>idempotencyKey</c> makes a retried settle/revert a no-op.
|
||||
/// </summary>
|
||||
public interface IBnplProvider
|
||||
{
|
||||
/// <summary>Full reversal of a BNPL order back through the provider.</summary>
|
||||
/// <summary>Checks whether the customer may finance this order — records <c>eligibility_status</c>.</summary>
|
||||
ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Issues the <c>external_payment_token</c> + the redirect URL that starts the customer's order.</summary>
|
||||
ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Confirms the order — echoes the amount so the handler can re-check it server-side (never trust the callback alone).</summary>
|
||||
ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Settles the full order to Balinyaar net of the provider commission — returns the net amount, the
|
||||
/// commission (a platform expense), and the per-transaction <c>settled_at</c>.</summary>
|
||||
ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The provider's current view of the order (reconciliation/support).</summary>
|
||||
ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Cancels an order before settle.</summary>
|
||||
ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Full reversal of a settled BNPL order back through the provider (a <c>bnpl_revert</c> refund).</summary>
|
||||
ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Partial revert — reduces the order to a strictly-lower <paramref name="newAmountIrr"/>.</summary>
|
||||
ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of a BNPL revert/update.</summary>
|
||||
/// <summary>The eligibility outcome + plan summary the client shows (or falls back to card on).</summary>
|
||||
/// <param name="EligibilityStatus">A <c>BnplEligibilityStatus</c> code (<c>eligible</c>/<c>not_eligible</c>/<c>ceiling_exceeded</c>).</param>
|
||||
/// <param name="InstallmentCount">Informational plan length (default 4 — owned by the provider).</param>
|
||||
/// <param name="CreditCeilingIrr">The customer's provider credit ceiling (IRR), when known.</param>
|
||||
/// <param name="PlanSummary">Human copy, e.g. "4 interest-free installments, provider-financed".</param>
|
||||
public sealed record BnplEligibilityResult(
|
||||
string EligibilityStatus,
|
||||
int InstallmentCount,
|
||||
long? CreditCeilingIrr,
|
||||
string PlanSummary);
|
||||
|
||||
/// <param name="Status">Whether a token was issued.</param>
|
||||
/// <param name="ExternalPaymentToken">The deterministic token persisted for verify/settle/revert.</param>
|
||||
/// <param name="RedirectUrl">Where the customer is sent to complete the BNPL order.</param>
|
||||
/// <param name="ExternalTransactionId">The provider's order/txn id, when issued.</param>
|
||||
public sealed record BnplTokenResult(
|
||||
PaymentProviderStatus Status,
|
||||
string ExternalPaymentToken,
|
||||
string RedirectUrl,
|
||||
string? ExternalTransactionId);
|
||||
|
||||
/// <param name="Status">The re-verified outcome — only <see cref="PaymentProviderStatus.Succeeded"/> confirms.</param>
|
||||
/// <param name="OrderAmountIrr">The order amount the provider reports, re-checked against the stored order.</param>
|
||||
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
|
||||
public sealed record BnplVerifyResult(
|
||||
PaymentProviderStatus Status,
|
||||
long OrderAmountIrr,
|
||||
string? ExternalTransactionId);
|
||||
|
||||
/// <param name="Status">Whether the provider settled.</param>
|
||||
/// <param name="SettledAmountIrr">Net of the provider commission actually received (IRR).</param>
|
||||
/// <param name="BnplCommissionIrr">The provider's merchant discount (IRR) = a platform expense.</param>
|
||||
/// <param name="SettledAt">The per-transaction settlement time — <b>nullable</b>, contract-defined, never assumed instant.</param>
|
||||
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
|
||||
public sealed record BnplSettleResult(
|
||||
PaymentProviderStatus Status,
|
||||
long SettledAmountIrr,
|
||||
long BnplCommissionIrr,
|
||||
DateTime? SettledAt,
|
||||
string? ExternalTransactionId);
|
||||
|
||||
/// <param name="ProviderStatus">The provider's own status string for the order.</param>
|
||||
public sealed record BnplStatusResult(string ProviderStatus);
|
||||
|
||||
/// <summary>The outcome of a BNPL revert/update/cancel.</summary>
|
||||
/// <param name="Status">Whether the provider accepted the reversal.</param>
|
||||
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund.</param>
|
||||
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund + the BNPL row.</param>
|
||||
/// <param name="ProviderCommissionReversedAmount">The provider's own commission it returned — <b>nullable</b>,
|
||||
/// reconciled from the response, never hardcoded (some providers keep their fee on a refund).</param>
|
||||
public sealed record BnplRevertResult(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <see cref="IBnplProvider"/> impl for a <c>provider_code</c> (<c>snapppay</c>/<c>digipay</c>/
|
||||
/// <c>tara</c>/<c>torobpay</c>) — config-driven selection, <b>never an <c>if (mock)</c> branch in a handler</b>.
|
||||
/// A real system maps each code to its concrete adapter (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>,
|
||||
/// …); this phase ships the mock behind every known code and a single active route. Returns <c>null</c> for an
|
||||
/// unknown/unconfigured code so the handler can reject it cleanly rather than throw.
|
||||
/// </summary>
|
||||
public interface IBnplProviderResolver
|
||||
{
|
||||
IBnplProvider? Resolve(string providerCode);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Toman ↔ Rial (IRR) conversion <b>at the provider boundary only</b> — the provider speaks Toman, our books
|
||||
/// speak IRR. Conversion happens <b>solely</b> here, never internally: everything inside the domain is already
|
||||
/// IRR <c>long</c>. The mock multiplies Toman ×10 → IRR (and divides back for display); the multiplier is
|
||||
/// config-driven so a currency redenomination is a config change, not a code change.
|
||||
/// </summary>
|
||||
public interface ICurrencyNormalizer
|
||||
{
|
||||
/// <summary>Normalizes an amount in <paramref name="currency"/> (<c>IRR</c>/<c>TOMAN</c>) to IRR.</summary>
|
||||
long ToIrr(long amount, string currency);
|
||||
|
||||
/// <summary>Converts an IRR amount back to Toman for display only.</summary>
|
||||
long ToDisplayToman(long amountIrr);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The BNPL aggregate — one <c>bnpl_transactions</c> row per order (1:1 with its <c>payment_transaction</c>).
|
||||
/// Writes load tracked rows; reads project to DTOs. The ledger legs themselves are appended through
|
||||
/// <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper); this repo owns only the BNPL row, the
|
||||
/// order-context read, and the settle-ledger idempotency probe. Money is IRR <c>long</c>. The
|
||||
/// <c>UNIQUE(payment_transaction_id)</c> guard is the structural one-BNPL-row-per-order backstop.
|
||||
/// </summary>
|
||||
public interface IBnplRepository
|
||||
{
|
||||
/// <summary>Everything eligibility/initiate need for a booking request (owner + mobile + status + gross).
|
||||
/// Null when the request does not exist.</summary>
|
||||
Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked BNPL row for a payment transaction — the 1:1 lookup eligibility/initiate upsert on.
|
||||
/// Null when none exists yet.</summary>
|
||||
Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked BNPL row for a booking request (via its payment transaction) — the find-or-create
|
||||
/// anchor eligibility and initiate share so a request has at most one BNPL order. Null when none exists.</summary>
|
||||
Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked BNPL row carrying <paramref name="externalPaymentToken"/> — the callback dispatch
|
||||
/// resolves the order from the token in the payload. Null when absent.</summary>
|
||||
Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether the balanced settle group already exists for this BNPL row — makes the settle post
|
||||
/// idempotent so a replayed settle never writes a second net-of-fee group.</summary>
|
||||
Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA
|
||||
/// when reverted. Null when absent.</summary>
|
||||
Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -34,4 +34,8 @@ public interface IRefundRepository
|
||||
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
|
||||
/// Null when absent.</summary>
|
||||
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it
|
||||
/// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary>
|
||||
Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IUnitOfWork
|
||||
public IPaymentRepository PaymentRepository { get; }
|
||||
public IRefundRepository RefundRepository { get; }
|
||||
public IInvoiceRepository InvoiceRepository { get; }
|
||||
public IBnplRepository BnplRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The single find-or-create for a BNPL order's skeleton — the pending <c>payment_transaction</c> (bnpl gateway)
|
||||
/// plus the 1:1 <c>bnpl_transactions</c> row — shared by eligibility and initiate so a booking request has at
|
||||
/// most one BNPL order. The <c>UNIQUE(payment_transaction_id)</c> guard is the structural backstop; this helper
|
||||
/// is the friendly pre-check. It commits the two rows (the payment transaction id must exist before the BNPL FK)
|
||||
/// and returns the tracked BNPL row; the caller owns the provider call, the state transition and any lock.
|
||||
/// </summary>
|
||||
internal static class BnplOrderInitializer
|
||||
{
|
||||
public static async Task<BnplTransaction> EnsureAsync(
|
||||
IUnitOfWork unitOfWork,
|
||||
BnplOrderContext context,
|
||||
long gatewayId,
|
||||
string providerCode,
|
||||
string merchantOfRecord,
|
||||
long orderAmountIrr,
|
||||
string currency,
|
||||
string? ipAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await unitOfWork.BnplRepository.GetTrackedByBookingRequestIdAsync(context.RequestId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
// A BNPL order is a card payment landing net-of-fee, so it rides the same payment_transactions rail
|
||||
// (booking_id null until settle binds it, exactly like the card path).
|
||||
var transaction = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = context.RequestId,
|
||||
CustomerId = context.CustomerId,
|
||||
GatewayId = gatewayId,
|
||||
Amount = orderAmountIrr,
|
||||
Currency = "IRR",
|
||||
IsInstallment = true,
|
||||
IpAddress = ipAddress
|
||||
};
|
||||
await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var bnpl = new BnplTransaction
|
||||
{
|
||||
PaymentTransactionId = transaction.Id,
|
||||
ProviderCode = providerCode,
|
||||
MerchantOfRecord = merchantOfRecord,
|
||||
OrderAmountIrr = orderAmountIrr,
|
||||
Currency = currency,
|
||||
EligibilityStatus = null
|
||||
};
|
||||
await unitOfWork.BnplRepository.AddAsync(bnpl, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return bnpl;
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
|
||||
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
|
||||
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
|
||||
|
||||
internal sealed class HandleBnplCallbackCommandHandler(
|
||||
ISender sender,
|
||||
IUnitOfWork unitOfWork,
|
||||
IWebhookVerifier webhookVerifier,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
|
||||
{
|
||||
private enum CallbackAction { None, Verify, Settle, Revert }
|
||||
|
||||
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand 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);
|
||||
}
|
||||
|
||||
var action = ResolveAction(verification.EventType);
|
||||
if (action == CallbackAction.None || string.IsNullOrEmpty(verification.GatewayReferenceCode))
|
||||
{
|
||||
// Nothing to drive (unknown event / no token) — acknowledged, no money moves.
|
||||
webhookEvent.MarkProcessed(null, now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
var bnpl = await unitOfWork.BnplRepository.GetTrackedByTokenAsync(verification.GatewayReferenceCode!, cancellationToken);
|
||||
if (bnpl is null)
|
||||
{
|
||||
// No matching order for the token — retryable (failed), never a silent success.
|
||||
webhookEvent.MarkFailed(now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
// Claim the idempotency key first (inside the same context that mutates 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);
|
||||
}
|
||||
|
||||
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken);
|
||||
|
||||
if (dispatched)
|
||||
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
|
||||
else
|
||||
webhookEvent.MarkFailed(now);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = action switch
|
||||
{
|
||||
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess,
|
||||
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess,
|
||||
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess,
|
||||
_ => false
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CallbackAction ResolveAction(string eventType)
|
||||
{
|
||||
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
|
||||
return CallbackAction.Settle;
|
||||
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
|
||||
return CallbackAction.Verify;
|
||||
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
return CallbackAction.Revert;
|
||||
return CallbackAction.None;
|
||||
}
|
||||
|
||||
private async Task<OperationResult<BnplCallbackResult>> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await unitOfWork.RollBackAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: true);
|
||||
}
|
||||
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private static OperationResult<BnplCallbackResult> Success(string processingStatus, bool isDuplicate)
|
||||
=> OperationResult<BnplCallbackResult>.SuccessResult(new BnplCallbackResult(processingStatus, isDuplicate));
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
|
||||
|
||||
/// <summary>
|
||||
/// The inbound BNPL provider-callback entry point. Authenticated by <b>signature</b> (not a session);
|
||||
/// at-least-once tolerant and deduplicated on <c>(provider_code, external_event_id)</c> in
|
||||
/// <c>payment_webhook_events</c> before any money moves, then dispatched to verify/settle/revert by event type —
|
||||
/// all gated by the BNPL status state machine so a re-delivered callback never double-settles or double-posts.
|
||||
/// </summary>
|
||||
public record HandleBnplCallbackCommand(string Provider, IReadOnlyDictionary<string, string>? Headers, string? RawBody)
|
||||
: IRequest<OperationResult<BnplCallbackResult>>;
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
|
||||
|
||||
internal sealed class InitiateBnplOrderCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IBnplProviderResolver providerResolver,
|
||||
ICurrencyNormalizer currencyNormalizer,
|
||||
IPlatformConfig platformConfig,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<InitiateBnplOrderCommand, OperationResult<InitiateBnplResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<InitiateBnplResult>> Handle(InitiateBnplOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<InitiateBnplResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is null)
|
||||
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
|
||||
if (ctx is null || ctx.CustomerId != customerId)
|
||||
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var provider = providerResolver.Resolve(request.ProviderCode);
|
||||
if (provider is null)
|
||||
return OperationResult<InitiateBnplResult>.FailureResult("provider_code", "The BNPL provider is not available.");
|
||||
|
||||
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
|
||||
if (gatewayId is null)
|
||||
return OperationResult<InitiateBnplResult>.FailureResult("No active BNPL gateway is configured.");
|
||||
|
||||
// The whole money mutation runs under the lock; the DB uniques/state machine remain the authoritative
|
||||
// backstop if the lock is lost. Keyed on the request (a b9 booking exists only after settle).
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking-request:{request.BookingRequestId}:payment", cancellationToken);
|
||||
|
||||
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
|
||||
return OperationResult<InitiateBnplResult>.ConflictResult("This booking has already been paid.");
|
||||
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return OperationResult<InitiateBnplResult>.ConflictResult("This request is not awaiting payment.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
if (ctx.PaymentDeadlineAt is { } deadline && deadline < now)
|
||||
return OperationResult<InitiateBnplResult>.ConflictResult("The payment window for this request has lapsed.");
|
||||
|
||||
// Conversion happens ONLY here, at the provider boundary. The gross is already IRR, so this is a no-op
|
||||
// today — but a real Toman-quoting provider is normalized through exactly this seam, never internally.
|
||||
var orderAmountIrr = currencyNormalizer.ToIrr(ctx.GrossIrr, "IRR");
|
||||
|
||||
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
|
||||
var bnpl = await BnplOrderInitializer.EnsureAsync(
|
||||
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
|
||||
orderAmountIrr, "IRR", currentUser.IpAddress, cancellationToken);
|
||||
|
||||
// Only an eligible or already-tokenized order can be (re-)initiated; a settled/reverted/cancelled/failed
|
||||
// one cannot. The provider token call is idempotent, so a replay returns the same token + redirect.
|
||||
if (bnpl.Status is not (BnplStatus.Eligible or BnplStatus.TokenIssued))
|
||||
return OperationResult<InitiateBnplResult>.ConflictResult("This BNPL order can no longer be started.");
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
|
||||
? $"bnpl-br-{request.BookingRequestId}"
|
||||
: request.IdempotencyKey!;
|
||||
|
||||
var token = await provider.CreatePaymentTokenAsync(ctx.CustomerMobile, orderAmountIrr, idempotencyKey, cancellationToken);
|
||||
if (token.Status != PaymentProviderStatus.Succeeded)
|
||||
{
|
||||
if (bnpl.Status == BnplStatus.Eligible)
|
||||
{
|
||||
bnpl.MarkFailed();
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
return OperationResult<InitiateBnplResult>.FailureResult("The BNPL provider declined the order.");
|
||||
}
|
||||
|
||||
// On the first initiate, bind the token to the pending payment_transaction so the callback can find it
|
||||
// (and the filtered UNIQUE(gateway_reference_code) guards it), then walk eligible → token_issued.
|
||||
if (bnpl.Status == BnplStatus.Eligible)
|
||||
{
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
|
||||
if (transaction is not null)
|
||||
transaction.GatewayReferenceCode = token.ExternalPaymentToken;
|
||||
|
||||
bnpl.MarkTokenIssued(token.ExternalPaymentToken, token.ExternalTransactionId);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
return OperationResult<InitiateBnplResult>.SuccessResult(new InitiateBnplResult(
|
||||
bnpl.Id, bnpl.PaymentTransactionId, bnpl.Status, token.ExternalPaymentToken, token.RedirectUrl));
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
|
||||
|
||||
public sealed class InitiateBnplOrderCommandValidator : AbstractValidator<InitiateBnplOrderCommand>
|
||||
{
|
||||
public InitiateBnplOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingRequestId).GreaterThan(0);
|
||||
RuleFor(x => x.ProviderCode)
|
||||
.NotEmpty()
|
||||
.Must(BnplProviderCodes.IsKnown)
|
||||
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a BNPL order for an <c>accepted_awaiting_payment</c> booking request owned by the caller: ensures the
|
||||
/// 1:1 <c>bnpl_transactions</c> row (under the <c>UNIQUE(payment_transaction_id)</c> guard), normalizes the
|
||||
/// order amount to IRR at the provider boundary, asks the provider for a payment token, transitions
|
||||
/// <c>eligible → token_issued</c>, and returns the token + redirect. Runs under <c>lock(booking:{id}:payment)</c>
|
||||
/// and carries an idempotency key so a retried start reuses the same token.
|
||||
/// </summary>
|
||||
public record InitiateBnplOrderCommand(long BookingRequestId, string ProviderCode, string? IdempotencyKey)
|
||||
: IRequest<OperationResult<InitiateBnplResult>>;
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
|
||||
|
||||
internal sealed class RevertBnplOrderCommandHandler(
|
||||
ISender sender,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<RevertBnplOrderCommand, OperationResult<RevertBnplResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RevertBnplResult>> Handle(RevertBnplOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
|
||||
if (bnpl is null)
|
||||
return OperationResult<RevertBnplResult>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
// A replayed revert must not double-refund — an already-reverted order is a clean conflict.
|
||||
if (bnpl.Status == BnplStatus.Reverted)
|
||||
return OperationResult<RevertBnplResult>.ConflictResult("This BNPL order has already been reverted.");
|
||||
// Only a settled order has a captured booking + ledger to reverse.
|
||||
if (bnpl.Status != BnplStatus.Settled)
|
||||
return OperationResult<RevertBnplResult>.ConflictResult("Only a settled BNPL order can be reverted.");
|
||||
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
|
||||
if (transaction?.BookingId is not { } bookingId)
|
||||
return OperationResult<RevertBnplResult>.ConflictResult("The BNPL order has no captured booking to reverse.");
|
||||
|
||||
// Reuse the b11 refund path: it creates the refunds row (refund_channel='bnpl_revert'), executes the
|
||||
// provider revert/update behind the seam, posts the balanced reversal ledger (+ clawback fork), and
|
||||
// surfaces the async customer ETA. It owns lock(booking:{id}:refund), so we do not lock here.
|
||||
var refund = await sender.Send(new CreateRefundCommand(
|
||||
BookingId: bookingId,
|
||||
TicketId: request.TicketId,
|
||||
RefundPercentage: request.RefundPercentage ?? 1m,
|
||||
PlatformFeeRefundedIrr: null,
|
||||
NursePayoutRefundedIrr: null,
|
||||
ReasonCategory: "bnpl_revert",
|
||||
ReasonNotes: request.ReasonNotes,
|
||||
AdminNotes: null,
|
||||
ManualBankReference: null), cancellationToken);
|
||||
|
||||
if (!refund.IsSuccess)
|
||||
return new OperationResult<RevertBnplResult>
|
||||
{
|
||||
IsSuccess = false,
|
||||
IsNotFound = refund.IsNotFound,
|
||||
IsConflict = refund.IsConflict,
|
||||
IsForbidden = refund.IsForbidden,
|
||||
IsUnauthorized = refund.IsUnauthorized,
|
||||
IsException = refund.IsException,
|
||||
ErrorMessages = refund.ErrorMessages
|
||||
};
|
||||
|
||||
var refundResult = refund.Result;
|
||||
var revertedAmount = long.TryParse(refundResult.Amount, out var amount) ? amount : 0;
|
||||
var externalRef = await unitOfWork.RefundRepository.GetExternalRevertReferenceAsync(refundResult.RefundId, cancellationToken);
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
// provider_commission_reversed_amount is reconciled from the provider response later — nullable here
|
||||
// (some providers keep their fee on a refund; the b11 refund path does not persist it).
|
||||
bnpl.MarkReverted(externalRef, revertedAmount, providerCommissionReversedAmount: null, now, request.CallbackPayloadJson);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<RevertBnplResult>.SuccessResult(new RevertBnplResult(
|
||||
bnpl.Id, refundResult.RefundId, bnpl.Status, externalRef,
|
||||
revertedAmount.ToString(), refundResult.ExpectedCustomerRefundEta));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
|
||||
|
||||
public sealed class RevertBnplOrderCommandValidator : AbstractValidator<RevertBnplOrderCommand>
|
||||
{
|
||||
public RevertBnplOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BnplTransactionId).GreaterThan(0);
|
||||
|
||||
// A partial revert must be strictly lower than the full order (RefundPercentage < 1); 1 = full revert.
|
||||
When(x => x.RefundPercentage.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.RefundPercentage!.Value)
|
||||
.GreaterThan(0m).LessThanOrEqualTo(1m)
|
||||
.WithMessage("refund_percentage must be a fraction in (0, 1].");
|
||||
});
|
||||
|
||||
RuleFor(x => x.ReasonNotes).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Reverses a <c>settled</c> BNPL order back through the provider (full via revert; partial/shortened-visit via
|
||||
/// update to a strictly-lower amount) and records the reversal audit on the <c>bnpl_transactions</c> row. It
|
||||
/// <b>reuses the b11 refund path</b> (<c>CreateRefundCommand</c>) — which creates the <c>refunds</c> row with
|
||||
/// <c>refund_channel='bnpl_revert'</c>, executes the provider revert, posts the balanced reversal ledger (fee +
|
||||
/// payout legs; a clawback if the nurse was already paid), and surfaces the async ~7–10-business-day customer
|
||||
/// ETA. Money <b>always</b> flows customer ↔ provider ↔ Balinyaar. Admin-only.
|
||||
/// </summary>
|
||||
/// <param name="BnplTransactionId">The settled BNPL order to reverse.</param>
|
||||
/// <param name="RefundPercentage">The reversed fraction (0–1]; 1 = full revert, <1 = a strictly-lower partial.</param>
|
||||
/// <param name="TicketId">Optional support-ticket link (config-gated "ticket required" rule, b15 FK).</param>
|
||||
/// <param name="ReasonNotes">Free-text reason recorded on the refund.</param>
|
||||
public record RevertBnplOrderCommand(
|
||||
long BnplTransactionId,
|
||||
decimal? RefundPercentage,
|
||||
long? TicketId,
|
||||
string? ReasonNotes,
|
||||
string? CallbackPayloadJson = null) : IRequest<OperationResult<RevertBnplResult>>;
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
#nullable enable
|
||||
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.Bnpl;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
|
||||
|
||||
/// <summary>
|
||||
/// The BNPL money capture. Mirrors b10's <c>ConfirmPaymentAndPostLedger</c> — same shared booking conversion,
|
||||
/// same idempotency shape — but posts the <b>net-of-fee</b> group (via <c>LedgerPosting.BnplSettle</c>) so escrow
|
||||
/// reflects the real cash received, and verifies through the BNPL provider rather than the card PSP. The nurse's
|
||||
/// <c>nurse_payable</c> accrual is <b>invariant to payment method</b>: it comes from the booking split, never
|
||||
/// from <c>settled_amount_irr</c>.
|
||||
/// </summary>
|
||||
internal sealed class SettleBnplOrderCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IBnplProviderResolver providerResolver,
|
||||
IPlatformConfig platformConfig,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer,
|
||||
ISettlementSplitProvider settlementSplitProvider,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<SettleBnplOrderCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SettleBnplOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Key the lock on the BNPL order (1:1 with the booking) and load state under it, so a racing double
|
||||
// settle can't both read 'verified'. The webhook dedup + settle-ledger probe are the DB backstops.
|
||||
await using var _ = await distributedLock.AcquireAsync($"bnpl:{request.BnplTransactionId}:settle", cancellationToken);
|
||||
|
||||
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
|
||||
if (bnpl is null)
|
||||
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
// Already settled — idempotent no-op (a replayed settle must not re-post the ledger).
|
||||
if (bnpl.Status == BnplStatus.Settled)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
if (bnpl.Status != BnplStatus.Verified || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
|
||||
return OperationResult<bool>.ConflictResult("This BNPL order is not ready to settle.");
|
||||
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
|
||||
if (transaction is null)
|
||||
return OperationResult<bool>.NotFoundResult("The BNPL payment transaction is missing.");
|
||||
|
||||
var provider = providerResolver.Resolve(bnpl.ProviderCode);
|
||||
if (provider is null)
|
||||
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
|
||||
? $"bnpl-settle-{bnpl.Id}"
|
||||
: request.IdempotencyKey!;
|
||||
|
||||
var settle = await provider.SettleAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, idempotencyKey, cancellationToken);
|
||||
if (settle.Status != PaymentProviderStatus.Succeeded)
|
||||
{
|
||||
bnpl.MarkFailed();
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.FailureResult("The BNPL provider declined the settlement.");
|
||||
}
|
||||
|
||||
// Never trust the settlement blindly — the net + commission must reconcile to the stored order amount.
|
||||
if (settle.SettledAmountIrr < 0 || settle.BnplCommissionIrr < 0
|
||||
|| settle.SettledAmountIrr + settle.BnplCommissionIrr != bnpl.OrderAmountIrr)
|
||||
return OperationResult<bool>.FailureResult("The BNPL settlement does not reconcile with the order amount.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Create/confirm the booking through the shared conversion (same path as the card capture).
|
||||
var conversion = await BookingConversion.EnsureBookingAsync(
|
||||
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
|
||||
if (conversion is null)
|
||||
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
|
||||
|
||||
transaction.MarkSucceeded(conversion.BookingId, BnplStatus.Settled, transaction.GatewayResponseJson);
|
||||
|
||||
// Idempotent net-of-fee ledger: the card-capture legs PLUS the bnpl_fee_expense leg, under one balanced
|
||||
// group, so escrow_held reflects the NET cash. The nurse_payable leg equals the card-path amount.
|
||||
if (!await unitOfWork.BnplRepository.SettleLedgerExistsAsync(bnpl.Id, cancellationToken))
|
||||
{
|
||||
var legs = LedgerPosting.BnplSettle(
|
||||
conversion.BookingId, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr,
|
||||
conversion.PayoutIrr, settle.BnplCommissionIrr, bnpl.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
}
|
||||
|
||||
bnpl.MarkSettled(settle.SettledAmountIrr, settle.BnplCommissionIrr, settle.SettledAt, request.CallbackPayloadJson);
|
||||
if (settle.ExternalTransactionId is not null)
|
||||
transaction.GatewayTransactionId ??= settle.ExternalTransactionId;
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent confirm for 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 — the nurse's payout is invariant to payment method (from the booking split,
|
||||
// never from settled_amount). The provider commission is a platform expense, not the nurse's.
|
||||
await settlementSplitProvider.RegisterSplitAsync(
|
||||
conversion.BookingId,
|
||||
[
|
||||
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
|
||||
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
|
||||
],
|
||||
cancellationToken);
|
||||
|
||||
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
|
||||
await NotifyConfirmedAsync(customerUserId, nurseUserId, conversion.BookingId, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(customerUserId, "booking_confirmed", "Booking confirmed",
|
||||
"Your installment plan was approved and your booking is confirmed.", payload),
|
||||
cancellationToken);
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(nurseUserId, "booking_confirmed_nurse", "New confirmed booking",
|
||||
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Settles a <c>verified</c> BNPL order: the provider pays the full order net of its commission, so this records
|
||||
/// <c>settled_amount_irr</c>/<c>bnpl_commission_irr</c>/<c>settled_at</c> from the <b>actual settlement</b>,
|
||||
/// posts the <b>net-of-fee</b> ledger group (card-capture legs PLUS the <c>bnpl_fee_expense</c> leg), confirms
|
||||
/// the parent <c>payment_transaction</c> (which triggers the booking conversion), and transitions
|
||||
/// <c>verified → settled</c>. Under <c>lock(bnpl:{id}:settle)</c>; carries an idempotency key. A replayed settle
|
||||
/// is a no-op (state guard + webhook dedup + the settle-ledger idempotency probe).
|
||||
/// </summary>
|
||||
public record SettleBnplOrderCommand(long BnplTransactionId, string? IdempotencyKey = null, string? CallbackPayloadJson = null)
|
||||
: IRequest<OperationResult<bool>>;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
|
||||
|
||||
internal sealed class VerifyBnplOrderCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IBnplProviderResolver providerResolver)
|
||||
: IRequestHandler<VerifyBnplOrderCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(VerifyBnplOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
|
||||
if (bnpl is null)
|
||||
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
// Already verified/settled — idempotent no-op (a replayed callback must not re-drive the transition).
|
||||
if (bnpl.Status is BnplStatus.Verified or BnplStatus.Settled)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
if (bnpl.Status != BnplStatus.TokenIssued || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
|
||||
return OperationResult<bool>.ConflictResult("This BNPL order is not awaiting verification.");
|
||||
|
||||
var provider = providerResolver.Resolve(bnpl.ProviderCode);
|
||||
if (provider is null)
|
||||
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
|
||||
|
||||
var verify = await provider.VerifyAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, cancellationToken);
|
||||
|
||||
// Never trust the callback alone — the provider must confirm and the amount must match the stored order.
|
||||
if (verify.Status != PaymentProviderStatus.Succeeded || verify.OrderAmountIrr != bnpl.OrderAmountIrr)
|
||||
{
|
||||
bnpl.MarkFailed();
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.FailureResult("The BNPL order could not be verified with the provider.");
|
||||
}
|
||||
|
||||
bnpl.MarkVerified(verify.ExternalTransactionId, request.CallbackPayloadJson);
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms a <c>token_issued</c> BNPL order with the provider and re-checks the amount + reference server-side
|
||||
/// (<b>never trust the callback alone</b>), then transitions <c>token_issued → verified</c>. Driven by the
|
||||
/// provider callback and by the admin verify endpoint. Idempotent via the status state guard — an already
|
||||
/// verified/settled order is a no-op success.
|
||||
/// </summary>
|
||||
public record VerifyBnplOrderCommand(long BnplTransactionId, string? CallbackPayloadJson = null)
|
||||
: IRequest<OperationResult<bool>>;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
||||
|
||||
internal sealed class CheckBnplEligibilityQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IBnplProviderResolver providerResolver,
|
||||
IPlatformConfig platformConfig)
|
||||
: IRequestHandler<CheckBnplEligibilityQuery, OperationResult<BnplEligibilityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BnplEligibilityDto>> Handle(CheckBnplEligibilityQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BnplEligibilityDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is null)
|
||||
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
// Tenancy: only the owning customer may finance; any other caller must not learn the request exists.
|
||||
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
|
||||
if (ctx is null || ctx.CustomerId != customerId)
|
||||
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
|
||||
return OperationResult<BnplEligibilityDto>.ConflictResult("This booking has already been paid.");
|
||||
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return OperationResult<BnplEligibilityDto>.ConflictResult("This request is not awaiting payment.");
|
||||
|
||||
var provider = providerResolver.Resolve(request.ProviderCode);
|
||||
if (provider is null)
|
||||
return OperationResult<BnplEligibilityDto>.FailureResult("provider_code", "The BNPL provider is not available.");
|
||||
|
||||
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
|
||||
if (gatewayId is null)
|
||||
return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured.");
|
||||
|
||||
var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken);
|
||||
|
||||
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
|
||||
var bnpl = await BnplOrderInitializer.EnsureAsync(
|
||||
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
|
||||
ctx.GrossIrr, "IRR", currentUser.IpAddress, cancellationToken);
|
||||
|
||||
bnpl.EligibilityStatus = eligibility.EligibilityStatus;
|
||||
bnpl.InstallmentCount = (byte)eligibility.InstallmentCount;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var isEligible = eligibility.EligibilityStatus == BnplEligibilityStatus.Eligible;
|
||||
return OperationResult<BnplEligibilityDto>.SuccessResult(new BnplEligibilityDto(
|
||||
eligibility.EligibilityStatus,
|
||||
isEligible,
|
||||
eligibility.InstallmentCount,
|
||||
eligibility.PlanSummary,
|
||||
eligibility.CreditCeilingIrr?.ToString()));
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
||||
|
||||
public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator<CheckBnplEligibilityQuery>
|
||||
{
|
||||
public CheckBnplEligibilityQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingRequestId).GreaterThan(0);
|
||||
RuleFor(x => x.ProviderCode)
|
||||
.NotEmpty()
|
||||
.Must(BnplProviderCodes.IsKnown)
|
||||
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a family can finance an <c>accepted_awaiting_payment</c> booking request with the chosen BNPL
|
||||
/// provider, and records the outcome on a created/updated <c>bnpl_transactions</c> row (status <c>eligible</c>).
|
||||
/// The order amount is the request's frozen gross (variant price × session count) — never client-supplied. The
|
||||
/// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer.
|
||||
/// </summary>
|
||||
/// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param>
|
||||
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>).</param>
|
||||
public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode)
|
||||
: IRequest<OperationResult<BnplEligibilityDto>>;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
|
||||
|
||||
internal sealed class GetBnplOrderStatusQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<GetBnplOrderStatusQuery, OperationResult<BnplOrderStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BnplOrderStatusDto>> Handle(GetBnplOrderStatusQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.BnplRepository.GetStatusAsync(request.BnplTransactionId, cancellationToken);
|
||||
if (projection is null)
|
||||
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
// Customer view: a cross-customer access is indistinguishable from "not found" — never confirm it exists.
|
||||
if (!request.AdminView && projection.CustomerUserId != currentUser.UserId)
|
||||
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
return OperationResult<BnplOrderStatusDto>.SuccessResult(projection.Order);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces a BNPL order: status, the money split (gross/net/commission), the <b>non-instant</b> settlement time,
|
||||
/// and the revert audit + async customer ETA. <paramref name="AdminView"/> comes from the (policy-gated) admin
|
||||
/// controller; the customer view is tenancy-scoped to <c>ICurrentUser</c> — a customer can never read another's
|
||||
/// order (a cross-customer read is a clean not-found).
|
||||
/// </summary>
|
||||
public record GetBnplOrderStatusQuery(long BnplTransactionId, bool AdminView)
|
||||
: IRequest<OperationResult<BnplOrderStatusDto>>;
|
||||
@@ -0,0 +1,73 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Bookings;
|
||||
|
||||
/// <summary>
|
||||
/// The single place a captured payment turns an <c>accepted_awaiting_payment</c> request into a confirmed
|
||||
/// <c>bookings</c> row (through <see cref="BookingFactory"/>), shared by <b>both</b> capture rails — b10's card
|
||||
/// <c>ConfirmPaymentAndPostLedger</c> and b12's BNPL settle — so the conversion/idempotency logic lives once.
|
||||
/// It re-checks the tracked request against a racing cancel/expiry (a clean conflict, never a double
|
||||
/// conversion), creates the booking, and commits it; the caller owns the ledger posting for its rail (card
|
||||
/// capture vs the BNPL net-of-fee group) and any notifications. Pure orchestration — no capture, no
|
||||
/// current-user gate, no card/BNPL specifics.
|
||||
/// </summary>
|
||||
internal static class BookingConversion
|
||||
{
|
||||
/// <summary>Ensures the booking for <paramref name="bookingRequestId"/> exists (creating + committing it if
|
||||
/// the request is still convertible) and returns its frozen ledger amounts + participants. Null when the
|
||||
/// request can no longer be converted (already-terminal, racing cancel/expiry, or missing) — the caller
|
||||
/// maps that to a conflict. Idempotent: a booking created by a prior confirm is loaded, not re-created.</summary>
|
||||
public static async Task<BookingConversionResult?> EnsureBookingAsync(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer,
|
||||
long bookingRequestId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
|
||||
if (existingId is { } eb)
|
||||
{
|
||||
var existingAmounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
|
||||
return existingAmounts is null
|
||||
? null
|
||||
: new BookingConversionResult(eb, existingAmounts.NurseId, existingAmounts.GrossIrr, existingAmounts.CommissionIrr, existingAmounts.PayoutIrr, false, null, null);
|
||||
}
|
||||
|
||||
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
|
||||
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return 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;
|
||||
|
||||
trackedRequest.MarkConverted();
|
||||
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return new BookingConversionResult(
|
||||
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount,
|
||||
true, source.CustomerUserId, source.NurseUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The confirmed booking's identity, frozen three-amount split, whether this call created it, and (only
|
||||
/// on create) the participants to notify. Money is IRR <c>long</c>.</summary>
|
||||
internal sealed record BookingConversionResult(
|
||||
long BookingId,
|
||||
long NurseId,
|
||||
long GrossIrr,
|
||||
long CommissionIrr,
|
||||
long PayoutIrr,
|
||||
bool Created,
|
||||
int? CustomerUserId,
|
||||
int? NurseUserId);
|
||||
+13
-51
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -45,18 +44,20 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
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)
|
||||
// Create/confirm the booking through the shared conversion (idempotent on UNIQUE booking_request_id).
|
||||
var conversion = await BookingConversion.EnsureBookingAsync(
|
||||
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
|
||||
if (conversion is null)
|
||||
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
|
||||
|
||||
var booking = conversion.BookingId;
|
||||
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);
|
||||
booking, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr, conversion.PayoutIrr, transaction.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -77,68 +78,29 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
await settlementSplitProvider.RegisterSplitAsync(
|
||||
booking,
|
||||
[
|
||||
new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"),
|
||||
new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform")
|
||||
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
|
||||
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
|
||||
],
|
||||
cancellationToken);
|
||||
|
||||
if (created && participants is { } p)
|
||||
await NotifyConfirmedAsync(p, booking, cancellationToken);
|
||||
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
|
||||
await NotifyConfirmedAsync(customerUserId, nurseUserId, 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)
|
||||
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed",
|
||||
new Notification(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",
|
||||
new Notification(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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The facts eligibility/initiate need for an <c>accepted_awaiting_payment</c> booking request: the owning
|
||||
/// customer (tenancy) + their user id + mobile (for the provider eligibility/token call), the request status +
|
||||
/// frozen payment window, and the gross to finance (variant price × session count — the same figure b9 freezes
|
||||
/// onto the booking on capture). Money is IRR <c>long</c>.
|
||||
/// </summary>
|
||||
public record BnplOrderContext(
|
||||
long RequestId,
|
||||
long CustomerId,
|
||||
int CustomerUserId,
|
||||
string CustomerMobile,
|
||||
string Status,
|
||||
System.DateTime? PaymentDeadlineAt,
|
||||
long GrossIrr);
|
||||
|
||||
/// <summary>The eligibility result the client shows (or falls back to card on): the outcome, the plan summary,
|
||||
/// and whether the client should offer the "pay with installments" option.</summary>
|
||||
public record BnplEligibilityDto(
|
||||
string EligibilityStatus,
|
||||
bool IsEligible,
|
||||
int InstallmentCount,
|
||||
string PlanSummary,
|
||||
string? CreditCeilingIrr);
|
||||
|
||||
/// <summary>The result of starting a BNPL order: the token + provider redirect the client hands off to, plus the
|
||||
/// row + status. No money crosses back here — it is the request's frozen gross.</summary>
|
||||
public record InitiateBnplResult(
|
||||
long BnplTransactionId,
|
||||
long PaymentTransactionId,
|
||||
string Status,
|
||||
string ExternalPaymentToken,
|
||||
string RedirectUrl);
|
||||
|
||||
/// <summary>The admin/customer BNPL order view: status, the money split (gross/net/commission), the non-instant
|
||||
/// settlement time, and the revert audit. Money crosses the wire as digit strings; <c>settled_at</c> is nullable.</summary>
|
||||
public record BnplOrderStatusDto(
|
||||
long Id,
|
||||
long PaymentTransactionId,
|
||||
long? BookingId,
|
||||
string ProviderCode,
|
||||
string Status,
|
||||
string? EligibilityStatus,
|
||||
string OrderAmountIrr,
|
||||
string? SettledAmountIrr,
|
||||
string? BnplCommissionIrr,
|
||||
string Currency,
|
||||
int InstallmentCount,
|
||||
System.DateTime? SettledAt,
|
||||
string? RevertTransactionId,
|
||||
string? RevertedAmountIrr,
|
||||
System.DateTime? RevertedAt,
|
||||
string? ProviderCommissionReversedAmount,
|
||||
string? RefundChannel,
|
||||
System.DateOnly? ExpectedCustomerRefundEta,
|
||||
System.DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>The tenancy envelope for the customer BNPL-order read: the owning customer's user id (compared to
|
||||
/// <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
|
||||
public record BnplOrderStatusProjection(int CustomerUserId, BnplOrderStatusDto Order);
|
||||
|
||||
/// <summary>The outcome of ingesting a BNPL provider callback — the terminal processing status + whether it was a
|
||||
/// deduplicated replay. The endpoint is always success (at-least-once tolerant).</summary>
|
||||
public record BnplCallbackResult(string ProcessingStatus, bool Duplicate);
|
||||
|
||||
/// <summary>What <c>RevertBnplOrderCommand</c> returns — the reverted order, the created refund, the provider
|
||||
/// revert reference and the async customer cash-back ETA (~7–10 business days).</summary>
|
||||
public record RevertBnplResult(
|
||||
long BnplTransactionId,
|
||||
long RefundId,
|
||||
string Status,
|
||||
string? RevertTransactionId,
|
||||
string RevertedAmountIrr,
|
||||
System.DateOnly? ExpectedCustomerRefundEta);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Domain.Entities.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>bnpl_transactions.eligibility_status</c> code set recorded by the eligibility check — the
|
||||
/// client shows the installment plan on <see cref="Eligible"/> and falls back to card otherwise. These are the
|
||||
/// provider-agnostic outcomes; the concrete provider decides the code (credit ceiling, prior history, …).
|
||||
/// </summary>
|
||||
public static class BnplEligibilityStatus
|
||||
{
|
||||
/// <summary>The customer may use BNPL for this order amount.</summary>
|
||||
public const string Eligible = "eligible";
|
||||
|
||||
/// <summary>The customer is not eligible (no line/blocked) — the client falls back to card.</summary>
|
||||
public const string NotEligible = "not_eligible";
|
||||
|
||||
/// <summary>The order exceeds the customer's provider credit ceiling — the client falls back to card.</summary>
|
||||
public const string CeilingExceeded = "ceiling_exceeded";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Baya.Domain.Entities.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The closed set of supported <c>bnpl_transactions.provider_code</c> values — each selects one
|
||||
/// <c>IBnplProvider</c> impl through the resolver. All four Iranian provider-financed BNPLs are uniformly
|
||||
/// full-upfront / provider-bears-risk / interest-free-to-customer, so they share the mock behaviour; a real
|
||||
/// system maps each to its concrete adapter. Persisted as these stable snake_case codes.
|
||||
/// </summary>
|
||||
public static class BnplProviderCodes
|
||||
{
|
||||
public const string SnappPay = "snapppay";
|
||||
public const string Digipay = "digipay";
|
||||
public const string Tara = "tara";
|
||||
public const string TorobPay = "torobpay";
|
||||
|
||||
public static readonly IReadOnlyCollection<string> All = [SnappPay, Digipay, Tara, TorobPay];
|
||||
|
||||
public static bool IsKnown(string providerCode) => All.Contains(providerCode);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Baya.Domain.Entities.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>bnpl_transactions.status</c> code set — the <b>forward-only</b> state machine that is the
|
||||
/// idempotency spine of the BNPL money path. A replayed <c>settle</c>/<c>revert</c> that would re-drive a
|
||||
/// completed transition is an idempotent no-op (the handler treats an already-in-target state as done); the
|
||||
/// allowed edges live in <see cref="BnplTransitions"/>. Persisted as these stable snake_case codes.
|
||||
/// </summary>
|
||||
public static class BnplStatus
|
||||
{
|
||||
/// <summary>Eligibility checked and approved — the row exists, no token yet.</summary>
|
||||
public const string Eligible = "eligible";
|
||||
|
||||
/// <summary>A provider payment token was issued; the customer is redirected to complete the order.</summary>
|
||||
public const string TokenIssued = "token_issued";
|
||||
|
||||
/// <summary>The provider confirmed the order (amount + reference re-checked server-side).</summary>
|
||||
public const string Verified = "verified";
|
||||
|
||||
/// <summary>The provider settled the full order net-of-commission; the net-of-fee ledger group is posted.</summary>
|
||||
public const string Settled = "settled";
|
||||
|
||||
/// <summary>The order was reversed back through the provider (a <c>bnpl_revert</c> refund).</summary>
|
||||
public const string Reverted = "reverted";
|
||||
|
||||
/// <summary>The order was cancelled before settle. Terminal.</summary>
|
||||
public const string Cancelled = "cancelled";
|
||||
|
||||
/// <summary>The provider refused (eligibility/token/verify/settle). Terminal.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// One BNPL order, <b>1:1 with its <c>payment_transaction</c></b> (the <c>UNIQUE(payment_transaction_id)</c>
|
||||
/// guard) — the single inbound settlement to reconcile plus the revert path. In Balinyaar's books a BNPL order
|
||||
/// is a <b>card payment that lands net-of-fee</b>: there is nothing to amortize on our side (the provider owns
|
||||
/// the customer's installments and 100% of default risk), so this is one row, not a plan+entries tree.
|
||||
/// <para>
|
||||
/// <see cref="Status"/> is a <b>forward-only</b> state machine (<see cref="BnplTransitions"/>) mutated only
|
||||
/// through the cohesive mark-* methods; a replayed <c>settle</c>/<c>revert</c> that would re-drive a completed
|
||||
/// transition throws (the handler treats an already-in-target state as an idempotent no-op), so the ledger is
|
||||
/// never double-posted. Every amount is IRR <c>BIGINT</c> — currency is normalized to IRR at the provider
|
||||
/// boundary, never here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BnplTransaction : BaseEntity<long>
|
||||
{
|
||||
/// <summary>The 1:1 parent payment attempt — <c>UNIQUE</c> so exactly one BNPL row exists per order.</summary>
|
||||
public long PaymentTransactionId { get; set; }
|
||||
|
||||
/// <summary>Selects the provider impl — <c>snapppay</c> / <c>digipay</c> / <c>tara</c> / <c>torobpay</c>.</summary>
|
||||
public string ProviderCode { get; set; } = null!;
|
||||
|
||||
/// <summary>Balinyaar entity or partner center that is merchant-of-record for the order.</summary>
|
||||
public string MerchantOfRecord { get; set; } = null!;
|
||||
|
||||
/// <summary>The provider payment token, issued at initiate and used for verify/settle/revert.</summary>
|
||||
public string? ExternalPaymentToken { get; private set; }
|
||||
|
||||
/// <summary>The provider's own order/txn id, when returned.</summary>
|
||||
public string? ExternalTransactionId { get; private set; }
|
||||
|
||||
/// <summary>The recorded eligibility outcome — a <see cref="BnplEligibilityStatus"/> code.</summary>
|
||||
public string? EligibilityStatus { get; set; }
|
||||
|
||||
/// <summary>Gross order (IRR) = the booking's <c>gross_price_irr</c>. No floats, ever.</summary>
|
||||
public long OrderAmountIrr { get; set; }
|
||||
|
||||
/// <summary>Net of provider commission actually received (IRR) — set at settle from the real settlement.</summary>
|
||||
public long? SettledAmountIrr { get; private set; }
|
||||
|
||||
/// <summary>The provider's merchant discount (IRR) = a <b>platform expense</b>, never the nurse's — set at settle.</summary>
|
||||
public long? BnplCommissionIrr { get; private set; }
|
||||
|
||||
/// <summary><c>IRR</c>/<c>TOMAN</c> at the boundary; normalized to IRR on the way in.</summary>
|
||||
public string Currency { get; set; } = "IRR";
|
||||
|
||||
/// <summary>Informational only (default 4) — the installment schedule is owned by the provider.</summary>
|
||||
public byte InstallmentCount { get; set; } = 4;
|
||||
|
||||
/// <summary>Guarded — a <see cref="BnplStatus"/> code, mutated only through the mark-* methods.</summary>
|
||||
public string Status { get; private set; } = BnplStatus.Eligible;
|
||||
|
||||
/// <summary><b>Per-transaction</b>, contract-defined (daily/T+1–3/weekly) — nullable, never assumed instant.</summary>
|
||||
public DateTime? SettledAt { get; private set; }
|
||||
|
||||
// ---- reversal path ----
|
||||
public string? RevertTransactionId { get; private set; }
|
||||
public long? RevertedAmountIrr { get; private set; }
|
||||
public DateTime? RevertedAt { get; private set; }
|
||||
|
||||
/// <summary>The provider's own commission reversal — <b>nullable</b>, reconciled from the provider response,
|
||||
/// never hardcoded (some providers keep their fee on a refund).</summary>
|
||||
public long? ProviderCommissionReversedAmount { get; private set; }
|
||||
|
||||
/// <summary><c>bnpl_revert</c> on a reversal.</summary>
|
||||
public string? RefundChannel { get; private set; }
|
||||
|
||||
/// <summary>Raw verify/settle/revert payload for reconciliation and audit.</summary>
|
||||
public string? CallbackPayloadJson { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public bool CanTransitionTo(string target) => BnplTransitions.CanTransition(Status, target);
|
||||
|
||||
private void Transition(string target)
|
||||
{
|
||||
if (!BnplTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal BNPL transition {Status} → {target}.");
|
||||
Status = target;
|
||||
}
|
||||
|
||||
/// <summary>Records the provider token and transitions <c>eligible → token_issued</c>.</summary>
|
||||
public void MarkTokenIssued(string externalPaymentToken, string? externalTransactionId)
|
||||
{
|
||||
Transition(BnplStatus.TokenIssued);
|
||||
ExternalPaymentToken = externalPaymentToken;
|
||||
ExternalTransactionId ??= externalTransactionId;
|
||||
}
|
||||
|
||||
/// <summary>Server-side amount + reference re-checked by the handler; transitions <c>token_issued → verified</c>.</summary>
|
||||
public void MarkVerified(string? externalTransactionId, string? callbackPayloadJson)
|
||||
{
|
||||
Transition(BnplStatus.Verified);
|
||||
ExternalTransactionId ??= externalTransactionId;
|
||||
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
|
||||
}
|
||||
|
||||
/// <summary>Records the actual settlement (net + commission + per-transaction <paramref name="settledAt"/>)
|
||||
/// and transitions <c>verified → settled</c>. Enforces <c>settled = order − commission</c> and non-negativity
|
||||
/// so a settled row can never carry an unbalanced split.</summary>
|
||||
public void MarkSettled(long settledAmountIrr, long commissionIrr, DateTime? settledAt, string? callbackPayloadJson)
|
||||
{
|
||||
if (commissionIrr < 0 || settledAmountIrr < 0)
|
||||
throw new InvalidOperationException("BNPL settlement amounts must be non-negative.");
|
||||
if (settledAmountIrr != OrderAmountIrr - commissionIrr)
|
||||
throw new InvalidOperationException(
|
||||
$"BNPL settlement would not reconcile: settled {settledAmountIrr} != order {OrderAmountIrr} − commission {commissionIrr}.");
|
||||
|
||||
Transition(BnplStatus.Settled);
|
||||
SettledAmountIrr = settledAmountIrr;
|
||||
BnplCommissionIrr = commissionIrr;
|
||||
SettledAt = settledAt;
|
||||
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
|
||||
}
|
||||
|
||||
/// <summary>Records the provider-mediated reversal audit and transitions to <c>reverted</c>. The customer
|
||||
/// cash-back is async and owned by the provider — money flows customer ↔ provider ↔ Balinyaar only.</summary>
|
||||
public void MarkReverted(
|
||||
string? revertTransactionId, long revertedAmountIrr, long? providerCommissionReversedAmount,
|
||||
DateTime revertedAt, string? callbackPayloadJson)
|
||||
{
|
||||
Transition(BnplStatus.Reverted);
|
||||
RevertTransactionId = revertTransactionId;
|
||||
RevertedAmountIrr = revertedAmountIrr;
|
||||
ProviderCommissionReversedAmount = providerCommissionReversedAmount;
|
||||
RevertedAt = revertedAt;
|
||||
RefundChannel = Refunds.RefundChannel.BnplRevert;
|
||||
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
|
||||
}
|
||||
|
||||
public void MarkCancelled() => Transition(BnplStatus.Cancelled);
|
||||
|
||||
public void MarkFailed() => Transition(BnplStatus.Failed);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Baya.Domain.Entities.Bnpl;
|
||||
|
||||
/// <summary>
|
||||
/// The forward-only allowed-edge table for the <see cref="BnplStatus"/> machine (mirrors
|
||||
/// <c>BookingRequestTransitions</c>/<c>RefundTransitions</c>). Every write goes through
|
||||
/// <see cref="BnplTransaction"/>'s cohesive mark-* methods, which assert the edge here — an illegal transition
|
||||
/// throws, and a replayed callback that would re-drive a completed transition is rejected before it can
|
||||
/// re-post the ledger. Terminal states have no outgoing edge.
|
||||
/// </summary>
|
||||
public static class BnplTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[BnplStatus.Eligible] = [BnplStatus.TokenIssued, BnplStatus.Failed, BnplStatus.Cancelled],
|
||||
[BnplStatus.TokenIssued] = [BnplStatus.Verified, BnplStatus.Failed, BnplStatus.Cancelled],
|
||||
[BnplStatus.Verified] = [BnplStatus.Settled, BnplStatus.Failed, BnplStatus.Reverted, BnplStatus.Cancelled],
|
||||
[BnplStatus.Settled] = [BnplStatus.Reverted],
|
||||
// Terminal states — no outgoing edges.
|
||||
[BnplStatus.Reverted] = [],
|
||||
[BnplStatus.Cancelled] = [],
|
||||
[BnplStatus.Failed] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -36,6 +36,56 @@ public static class LedgerPosting
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>BNPL settle group</b>: the card-capture legs <b>plus</b> the provider-fee leg, all under one fresh
|
||||
/// <see cref="LedgerEntry.TransactionGroupId"/>, so <c>escrow_held</c> reflects the <b>net</b> cash actually
|
||||
/// received (<c>order − commission</c>), not the gross:
|
||||
/// <code>
|
||||
/// DEBIT escrow_held order (= gross)
|
||||
/// CREDIT platform_revenue commission
|
||||
/// CREDIT nurse_payable payout
|
||||
/// DEBIT bnpl_fee_expense bnpl_commission
|
||||
/// CREDIT escrow_held bnpl_commission
|
||||
/// </code>
|
||||
/// The three booking amounts come <b>frozen from the booking</b> (never recomputed) and must reconcile
|
||||
/// (<c>gross = commission + payout</c>); the <b>nurse's payout is invariant to payment method</b> — the BNPL
|
||||
/// commission is a platform expense and never touches it. Throws if the group would not balance, so an
|
||||
/// unbalanced settle can never be persisted. Posted once (the state guard + webhook dedup make a replay a no-op).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> BnplSettle(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long grossIrr,
|
||||
long commissionIrr,
|
||||
long payoutIrr,
|
||||
long bnplCommissionIrr,
|
||||
long bnplTransactionId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
if (grossIrr != commissionIrr + payoutIrr)
|
||||
throw new InvalidOperationException(
|
||||
$"BNPL settle group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
|
||||
if (bnplCommissionIrr < 0)
|
||||
throw new InvalidOperationException("BNPL provider commission must be non-negative.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
var legs = new List<LedgerEntry>
|
||||
{
|
||||
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
|
||||
Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
|
||||
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt)
|
||||
};
|
||||
|
||||
// The provider-fee leg: the merchant discount is a platform expense, so escrow reflects only the net cash.
|
||||
if (bnplCommissionIrr > 0)
|
||||
{
|
||||
legs.Add(Leg(group, LedgerAccountType.BnplFeeExpense, LedgerDirection.Debit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
|
||||
legs.Add(Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
|
||||
}
|
||||
|
||||
return legs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>pre-payout refund reversal</b> (the nurse has not been paid, the common case): <c>DEBIT
|
||||
/// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum)</c> under one group.
|
||||
|
||||
Reference in New Issue
Block a user