make account service with grn

This commit is contained in:
Dhananjaya99
2026-08-15 15:43:01 +05:30
parent ee9fa40edf
commit ae6a87022d
25 changed files with 8298 additions and 24 deletions
+25 -1
View File
@@ -11,8 +11,13 @@ namespace ERPCore.Controllers;
public sealed class GrnsController : ApiControllerBase public sealed class GrnsController : ApiControllerBase
{ {
private readonly IGrnService _grns; private readonly IGrnService _grns;
private readonly IGrnPaymentService _payments;
public GrnsController(IGrnService grns) => _grns = grns; public GrnsController(IGrnService grns, IGrnPaymentService payments)
{
_grns = grns;
_payments = payments;
}
/// <summary>List GRNs, newest first.</summary> /// <summary>List GRNs, newest first.</summary>
[HttpGet] [HttpGet]
@@ -59,4 +64,23 @@ public sealed class GrnsController : ApiControllerBase
public async Task<ActionResult<ReleaseLineResultDto>> Release( public async Task<ActionResult<ReleaseLineResultDto>> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct) int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct)); => Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{grnId:int}/payments")]
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(grnId, request, ct);
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
}
/// <summary>Payment history for this GRN, newest first.</summary>
[HttpGet("{grnId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
=> Ok(await _payments.ListAsync(grnId, ct));
} }
+10
View File
@@ -30,8 +30,18 @@ public class Grn
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; } public DateTime? PostedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
public decimal PaidAmount { get; set; }
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
public decimal BalanceAmount { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary> /// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; } public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>(); public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
} }
@@ -0,0 +1,31 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
/// </summary>
public class GrnPayment
{
public int GrnPaymentId { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
public long GlBankAccountId { get; set; }
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
public string? Reference { get; set; }
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
public string? GlJournalNo { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
}
+16 -3
View File
@@ -16,23 +16,36 @@ public sealed record GrnLineDto(
public sealed record GrnDto( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines); int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount); int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
decimal PaidAmount, decimal BalanceAmount);
public sealed record CreatedLayerDto( public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId, int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto( public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt, int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus); IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus); public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
public sealed record GrnPaymentDto(
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreateGrnPaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
// Requests ---------------------------------------------------------------------- // Requests ----------------------------------------------------------------------
public sealed class BatchInput public sealed class BatchInput
@@ -16,6 +16,9 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired(); builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired(); builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
builder.Property(g => g.RowVersion).IsRowVersion(); builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
@@ -28,6 +31,27 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
} }
} }
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
{
public void Configure(EntityTypeBuilder<GrnPayment> builder)
{
builder.ToTable("grn_payments");
builder.HasKey(p => p.GrnPaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.GrnId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine> public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{ {
public void Configure(EntityTypeBuilder<GrnLine> builder) public void Configure(EntityTypeBuilder<GrnLine> builder)
@@ -63,6 +63,7 @@ public class ErpDbContext : DbContext
// --- Goods Receipt (docs/10 Part C.3) --- // --- Goods Receipt (docs/10 Part C.3) ---
public DbSet<Grn> Grns => Set<Grn>(); public DbSet<Grn> Grns => Set<Grn>();
public DbSet<GrnLine> GrnLines => Set<GrnLine>(); public DbSet<GrnLine> GrnLines => Set<GrnLine>();
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
// --- Batch / Serial (docs/10 Part C.4) --- // --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>(); public DbSet<Batch> Batches => Set<Batch>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGrnGlPostingAndPayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "grns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "grns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "grns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
}
}
}
@@ -1406,6 +1406,10 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -1417,6 +1421,17 @@ namespace ERPCore.Migrations
.HasMaxLength(30) .HasMaxLength(30)
.HasColumnType("character varying(30)"); .HasColumnType("character varying(30)");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("PoId") b.Property<int?>("PoId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -1537,6 +1552,55 @@ namespace ERPCore.Migrations
b.ToTable("grn_lines", (string)null); b.ToTable("grn_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.Property<int>("GrnPaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("GrnPaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("GrnId");
b.ToTable("grn_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{ {
b.Property<int>("GrnLineWarrantyNumberId") b.Property<int>("GrnLineWarrantyNumberId")
@@ -3999,6 +4063,87 @@ namespace ERPCore.Migrations
b.ToTable("sales_return_lines", (string)null); b.ToTable("sales_return_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Property<int>("ReturnId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("ReasonCodeId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("ReturnId");
b.HasIndex("CreatedBy");
b.HasIndex("CustomerId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("ReasonCodeId");
b.HasIndex("WarehouseId");
b.ToTable("sales_returns", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.Property<int>("ReturnLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ReturnId")
.HasColumnType("integer");
b.Property<int?>("SalesInvoiceLineId")
.HasColumnType("integer");
b.HasKey("ReturnLineId");
b.HasIndex("ItemId");
b.HasIndex("ReturnId");
b.HasIndex("SalesInvoiceLineId");
b.ToTable("sales_return_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.Property<int>("SalesSlipId") b.Property<int>("SalesSlipId")
@@ -5831,6 +5976,25 @@ namespace ERPCore.Migrations
b.Navigation("PoLine"); b.Navigation("PoLine");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany("Payments")
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Grn");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
@@ -6528,6 +6692,67 @@ namespace ERPCore.Migrations
b.Navigation("SalesInvoiceLine"); b.Navigation("SalesInvoiceLine");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
.WithMany()
.HasForeignKey("ReasonCodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Customer");
b.Navigation("ReasonCode");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
.WithMany("Lines")
.HasForeignKey("ReturnId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
.WithMany()
.HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
b.Navigation("Return");
b.Navigation("SalesInvoiceLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -7025,6 +7250,13 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{
b.Navigation("WarrantyNumbers");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
@@ -7111,6 +7343,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
+1
View File
@@ -103,6 +103,7 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>(); builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>(); builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>(); builder.Services.AddScoped<IGrnService, GrnService>();
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
// Sales (Phase 1) // Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>(); builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
@@ -1,11 +1,21 @@
using System.Text;
using System.Text.Json;
using ERPCore.Infra.Gl; using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services; namespace ERPCore.Services;
/// <inheritdoc cref="IGeneralLedgerService"/> /// <inheritdoc cref="IGeneralLedgerService"/>
public sealed class GeneralLedgerService : IGeneralLedgerService public sealed class GeneralLedgerService : IGeneralLedgerService
{ {
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly IGeneralLedgerClient _client; private readonly IGeneralLedgerClient _client;
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client; public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
@@ -13,4 +23,89 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
public Task<GeneralLedgerResponse> ForwardAsync( public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> _client.SendAsync(method, path, queryString, contentType, body, ct); => _client.SendAsync(method, path, queryString, contentType, body, ct);
public async Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions));
var response = await _client.SendAsync(HttpMethod.Post, "journal-entries", null, "application/json", body, ct);
var data = ParseSuccess<GlJournalEntryData>(response);
return new GlJournalEntryResult(data.JournalNo, data.IsPosted);
}
public async Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
{
var response = await _client.SendAsync(
HttpMethod.Get, "fiscal-years/periods/by-date", $"?date={date:yyyy-MM-dd}", null, null, ct);
var data = ParseSuccess<GlPeriodData>(response);
return new GlPeriod(data.PeriodId, data.FiscalYearId);
}
public async Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
{
var response = await _client.SendAsync(HttpMethod.Get, "bank-accounts", "?accountType=Both", null, null, ct);
var data = ParseSuccess<List<GlBankAccountData>>(response);
return data.Select(a => new GlBankAccount(
a.AccountType, a.AccountId, a.AccountName, a.BankName,
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
}
/// <summary>
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
/// <see cref="DomainException"/> if the call didn't succeed.
/// </summary>
private static T ParseSuccess<T>(GeneralLedgerResponse response)
{
GlEnvelope<T>? envelope;
try
{
envelope = JsonSerializer.Deserialize<GlEnvelope<T>>(response.Body, JsonOptions);
}
catch (JsonException)
{
envelope = null;
}
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
{
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
}
return envelope.Data;
}
private sealed class GlEnvelope<T>
{
public int StatusCode { get; set; }
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
}
private sealed class GlJournalEntryData
{
public string JournalNo { get; set; } = string.Empty;
public bool IsPosted { get; set; }
}
private sealed class GlPeriodData
{
public int PeriodId { get; set; }
public int FiscalYearId { get; set; }
}
private sealed class GlBankAccountData
{
public string AccountType { get; set; } = string.Empty;
public long AccountId { get; set; }
public string AccountName { get; set; } = string.Empty;
public string? BankName { get; set; }
public string? CashAccountTypeName { get; set; }
public string? AccountNumber { get; set; }
public long GlAccountId { get; set; }
public string GlAccountCode { get; set; } = string.Empty;
public string CurrencyCode { get; set; } = string.Empty;
}
} }
+27
View File
@@ -0,0 +1,27 @@
namespace ERPCore.Services.Gl;
/// <summary>
/// Purpose-built request/response shapes for the specific GL endpoints ERPCore's GRN
/// module calls directly (journal-entries, period lookup, bank-account listing) — not
/// a full model of GL's contract (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6: typed
/// DTOs are added only for the flow that actually needs them).
/// </summary>
public sealed record GlJournalEntryLineRequest(string AccountCode, decimal DebitAmount, decimal CreditAmount, string? Memo = null);
public sealed class GlJournalEntryRequest
{
public int PeriodId { get; set; }
public DateOnly EntryDate { get; set; }
public string? SourceModule { get; set; }
public string? Reference { get; set; }
public string? Narration { get; set; }
public List<GlJournalEntryLineRequest> Lines { get; set; } = new();
}
public sealed record GlJournalEntryResult(string JournalNo, bool IsPosted);
public sealed record GlPeriod(int PeriodId, int FiscalYearId);
public sealed record GlBankAccount(
string AccountType, long AccountId, string AccountName, string? BankName,
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
@@ -0,0 +1,120 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class GrnPaymentService : IGrnPaymentService
{
private readonly IRepository<Grn> _grns;
private readonly IRepository<GrnPayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glClearingAccountCode;
public GrnPaymentService(
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_grns = grns;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
{
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
if (grn.Status == GrnStatus.Draft)
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
if (request.Amount > grn.BalanceAmount)
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "GRN_PAYMENT",
Reference = grn.DocNo,
Narration = $"Payment against GRN {grn.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
]
}, token);
var entity = new GrnPayment
{
GrnId = grn.GrnId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
grn.PaidAmount += request.Amount;
grn.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
{
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
throw new NotFoundException($"GRN {grnId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.GrnId == grnId)
.OrderByDescending(p => p.GrnPaymentId)
.Select(p => new GrnPaymentDto(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static GrnPaymentDto Map(GrnPayment p) => new(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
+47 -4
View File
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -38,13 +39,18 @@ public sealed class GrnService : IGrnService
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glVatRecoverableAccountCode;
private readonly string _glClearingAccountCode;
public GrnService( public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{ {
_grns = grns; _grns = grns;
_pos = pos; _pos = pos;
@@ -60,6 +66,10 @@ public sealed class GrnService : IGrnService
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
} }
public async Task<PagedResponse<GrnSummaryDto>> ListAsync( public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
@@ -82,7 +92,7 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count)) g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -260,15 +270,47 @@ public sealed class GrnService : IGrnService
} }
} }
// Post the real GL journal entry for this receipt before committing — if GL
// rejects it or is unreachable, the exception propagates out of this callback
// and the whole transaction (FIFO layers, stock ledger, PO accrual) rolls back
// with it, so inventory and the ledger never diverge (user-approved "fails
// atomically" behavior; residual risk if GL posts but the local commit still
// fails afterward is accepted for this phase, see the integration plan).
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var glLines = new List<GlJournalEntryLineRequest>();
decimal totalPayable = 0m;
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
if (line.VatAmount > 0)
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
totalPayable += line.LineTotal;
}
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "GRN",
Reference = grn.DocNo,
Narration = $"Goods received - GRN {grn.DocNo}",
Lines = glLines
}, token);
grn.Status = GrnStatus.Confirmed; grn.Status = GrnStatus.Confirmed;
grn.PostedAt = now; grn.PostedAt = now;
grn.GlJournalNo = posted.JournalNo;
grn.GlPostedAt = now;
grn.PaidAmount = 0m;
grn.BalanceAmount = totalPayable;
await UpdatePoStatusAsync(grn.PoId, token); await UpdatePoStatusAsync(grn.PoId, token);
return 0; return 0;
}, ct); }, ct);
return new GrnConfirmResultDto( return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now, grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
createdLayers.Select(ToCreatedLayer).ToList(), createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(), ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct)); await GetPoStatusAsync(grn.PoId, ct));
@@ -395,7 +437,7 @@ public sealed class GrnService : IGrnService
.Select(l => l.LedgerId).ToListAsync(ct); .Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto( return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct)); layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
} }
@@ -404,6 +446,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new( private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
@@ -1,16 +1,27 @@
using ERPCore.Infra.Gl; using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
namespace ERPCore.Services.Interfaces; namespace ERPCore.Services.Interfaces;
/// <summary> /// <summary>
/// Single entry point into the external General Ledger service — the one function /// Single entry point into the external General Ledger service — the one function
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend /// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to /// requests, forwarded verbatim) and by other ERPCore services that post directly to
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md). /// GL (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6). <see cref="ForwardAsync"/> stays a
/// No business logic lives here yet; this pass only connects the transport. /// byte-for-byte passthrough; the three typed methods below are the first internal
/// callers (GRN receipt + payment posting) and model only what those flows need.
/// </summary> /// </summary>
public interface IGeneralLedgerService public interface IGeneralLedgerService
{ {
Task<GeneralLedgerResponse> ForwardAsync( Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct); HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
/// <summary>Creates and posts a balanced journal entry. Throws <see cref="ERPCore.System.Errors.DomainException"/> on rejection/unreachability.</summary>
Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct);
/// <summary>Resolves the accounting period covering <paramref name="date"/>.</summary>
Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct);
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
} }
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
public interface IGrnPaymentService
{
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
}
@@ -70,4 +70,11 @@ public static class ErrorCodes
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12) // General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE"; public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED";
// GRN payments
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
} }
+5
View File
@@ -26,5 +26,10 @@
"BaseUrl": "https://localhost:7024/api/v1/", "BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" "ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
}, },
"Grn": {
"GlInventoryAccountCode": "1100",
"GlVatRecoverableAccountCode": "1200",
"GlClearingAccountCode": "2000"
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -11,8 +11,9 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn" import { ConfirmGrnResponse, Grn, GrnPayment } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data" import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -20,7 +21,8 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges" import { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
export default function GrnDetailPage() { export default function GrnDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
@@ -36,6 +38,9 @@ export default function GrnDetailPage() {
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null) const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
const [releasingLineId, setReleasingLineId] = useState<number | null>(null) const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [payments, setPayments] = useState<GrnPayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
// Stable per detail-page-session key so a retried confirm click doesn't double-post. // Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID()) const idempotencyKey = useRef(crypto.randomUUID())
@@ -55,6 +60,18 @@ export default function GrnDetailPage() {
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([])) warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
}, [grn?.warehouseId]) }, [grn?.warehouseId])
useEffect(() => {
if (!grn || grn.status === "Draft") return
grnsApi.listPayments(grn.grnId).then(setPayments).catch(() => setPayments([]))
}, [grn?.grnId, grn?.status])
function handlePaid(_updated: Grn, payment: GrnPayment) {
setGrn((prev) =>
prev ? { ...prev, paidAmount: prev.paidAmount + payment.amount, balanceAmount: prev.balanceAmount - payment.amount } : prev
)
setPayments((prev) => [payment, ...prev])
}
function itemFor(itemId: number) { function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId) return items.find((i) => i.itemId === itemId)
} }
@@ -70,8 +87,10 @@ export default function GrnDetailPage() {
try { try {
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current) const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
setConfirmResult(result) setConfirmResult(result)
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev)) setGrn((prev) =>
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`) prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo, balanceAmount: result.balanceAmount } : prev
)
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock. Journal ${result.glJournalNo}.`)
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
toast.error("Could not confirm GRN", errorMessage(err)) toast.error("Could not confirm GRN", errorMessage(err))
@@ -121,6 +140,7 @@ export default function GrnDetailPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
<GrnStatusBadge status={grn.status} /> <GrnStatusBadge status={grn.status} />
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
</div> </div>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} Vendor #{grn.vendorId} Warehouse #{grn.warehouseId} {grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} Vendor #{grn.vendorId} Warehouse #{grn.warehouseId}
@@ -128,6 +148,25 @@ export default function GrnDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
{grn.status !== "Draft" && (
<>
<Link
href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
title="View this GRN's postings in the General Ledger report"
>
<BookOpen className="size-5" />
View in Ledger
</Link>
{grn.balanceAmount > 0 && (
<Button size="lg" onClick={() => setPayDialogOpen(true)}>
<CircleDollarSign className="size-5" />
Pay
</Button>
)}
</>
)}
{grn.status === "Draft" && ( {grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}> <Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" /> <PackageCheck className="size-5" />
@@ -135,6 +174,9 @@ export default function GrnDetailPage() {
</Button> </Button>
)} )}
</div> </div>
</div>
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -150,6 +192,7 @@ export default function GrnDetailPage() {
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")} Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
</div> </div>
<div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.join(", ")}</div> <div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.join(", ")}</div>
<div className="text-sm text-muted-foreground">GL journal entry: {confirmResult.glJournalNo}</div>
{confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>} {confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>}
</div> </div>
)} )}
@@ -258,7 +301,7 @@ export default function GrnDetailPage() {
</Table> </Table>
</div> </div>
<div className="flex justify-end gap-8 border-t border-border pt-4 text-base"> <div className="flex flex-wrap justify-end gap-8 border-t border-border pt-4 text-base">
<div className="flex gap-3"> <div className="flex gap-3">
<span className="text-muted-foreground">Stock value (excl. VAT)</span> <span className="text-muted-foreground">Stock value (excl. VAT)</span>
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span> <span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
@@ -271,7 +314,49 @@ export default function GrnDetailPage() {
<span className="text-muted-foreground">Document total</span> <span className="text-muted-foreground">Document total</span>
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span> <span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
</div> </div>
{grn.status !== "Draft" && (
<>
<div className="flex gap-3">
<span className="text-muted-foreground">Amount paid</span>
<span className="tabular-nums">{formatAmount(grn.paidAmount)}</span>
</div> </div>
<div className="flex gap-3">
<span className="text-muted-foreground">Balance due</span>
<span className="font-semibold tabular-nums">{formatAmount(grn.balanceAmount)}</span>
</div>
</>
)}
</div>
{grn.status !== "Draft" && payments.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-foreground">Payment History</h2>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
<TableHead className="h-12 px-3 text-sm">Reference</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.grnPaymentId}>
<TableCell className="px-3 py-3.5">{new Date(p.paymentDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{p.bankAccountName}</TableCell>
<TableCell className="px-3 py-3.5">{p.reference ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{p.glJournalNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { GrnStatus, GrnSummary } from "@/types/grn" import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -22,7 +23,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { GrnStatusBadge } from "@/components/receiving/status-badges" import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All" type StatusFilter = GrnStatus | "All"
@@ -174,6 +175,8 @@ export default function GrnListPage() {
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> <TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Balance</TableHead>
<TableHead className="h-12 px-3 text-sm">Payment</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
@@ -196,6 +199,16 @@ export default function GrnListPage() {
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} /> <GrnStatusBadge status={grn.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{grn.status === "Draft" ? "—" : formatAmount(grn.balanceAmount)}
</TableCell>
<TableCell className="px-3 py-3.5">
{grn.status === "Draft" ? (
<span className="text-sm text-muted-foreground"></span>
) : (
<GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />
)}
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell> <TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -0,0 +1,145 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { Grn, GrnPayment } from "@/types/grn"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
interface GrnPaymentDialogProps {
grn: Grn | null
open: boolean
onOpenChange: (open: boolean) => void
onPaid: (grn: Grn, payment: GrnPayment) => void
}
/** Pay the vendor against a confirmed GRN's balance — full or partial, from an existing
* GL cash/bank account. Modeled on ReceivedChequeDialog's "pick an account, submit" shape. */
export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
const [amount, setAmount] = useState("")
const [reference, setReference] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Reset the form fields for a fresh open (or a different GRN) during render, React's own
// sanctioned "adjust state while rendering" pattern (see the General Ledger report page's
// periodKey comment) — not inside the effect below, which would be a synchronous
// setState-in-effect (react-hooks/set-state-in-effect).
const openKey = open && grn ? `${grn.grnId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setAmount(grn!.balanceAmount.toFixed(2))
setGlBankAccountId("")
setReference("")
setErrors({})
}
useEffect(() => {
if (!open || accounts !== null) return
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
}, [open, accounts])
if (!grn) return null
async function submit() {
if (!grn) return
const nextErrors: Record<string, string> = {}
const amountNum = Number(amount)
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from"
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
else if (amountNum > grn.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(grn.balanceAmount)})`
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const payment = await grnsApi.pay(grn.grnId, {
amount: amountNum,
glBankAccountId: Number(glBankAccountId),
reference: reference || undefined,
})
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
onPaid(grn, payment)
onOpenChange(false)
} catch (err) {
toast.error("Could not record payment", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Pay GRN {grn.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(grn.balanceAmount)}</span>
</p>
<FieldGroup>
<Field data-invalid={!!errors.glBankAccountId}>
<FieldLabel htmlFor="grn-pay-account">Pay from</FieldLabel>
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
<SelectTrigger id="grn-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
</SelectTrigger>
<SelectContent>
{(accounts ?? []).map((a) => (
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
{a.accountName} ({a.accountType})
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="grn-pay-amount">Amount</FieldLabel>
<Input
id="grn-pay-amount"
type="number"
step="0.01"
min="0.01"
max={grn.balanceAmount}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
/>
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="grn-pay-reference">Reference (optional)</FieldLabel>
<Input id="grn-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -32,3 +32,32 @@ export function HoldStatusBadge({ status }: { status: HoldStatus }) {
</Badge> </Badge>
) )
} }
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function paymentClass(status: GrnPaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const paymentLabel: Record<GrnPaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partial",
Paid: "Paid",
}
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = grnPaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
+12
View File
@@ -10,8 +10,10 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common" import { PagedResponse } from "@/types/common"
import { import {
ConfirmGrnResponse, ConfirmGrnResponse,
CreateGrnPaymentRequest,
CreateGrnRequest, CreateGrnRequest,
Grn, Grn,
GrnPayment,
GrnStatus, GrnStatus,
GrnSummary, GrnSummary,
ReleaseAction, ReleaseAction,
@@ -58,4 +60,14 @@ export const grnsApi = {
body: { action }, body: { action },
}) })
}, },
/** Pay the vendor against the GRN's balance, in full or in installments. Posts a real GL journal entry. */
pay(grnId: number, request: CreateGrnPaymentRequest): Promise<GrnPayment> {
return apiRequest<GrnPayment>(`/grns/${grnId}/payments`, { method: "POST", body: request })
},
/** Payment history for a GRN, newest first. */
listPayments(grnId: number): Promise<GrnPayment[]> {
return apiRequest<GrnPayment[]>(`/grns/${grnId}/payments`)
},
} }
@@ -288,6 +288,8 @@ export interface CashAndBankAccountDto {
cashAccountTypeName: string | null cashAccountTypeName: string | null
accountNumber: string | null accountNumber: string | null
glAccountId: number glAccountId: number
/** The underlying GL account's business code (added GL Phase 38, 2026-08-12). */
glAccountCode: string
currencyCode: string currencyCode: string
createdAt: string createdAt: string
} }
+28
View File
@@ -100,6 +100,10 @@ export interface Grn {
createdBy: number createdBy: number
createdAt: string createdAt: string
postedAt: string | null postedAt: string | null
/** Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm). */
glJournalNo: string | null
paidAmount: number
balanceAmount: number
lines: GrnLine[] lines: GrnLine[]
} }
@@ -114,6 +118,8 @@ export interface GrnSummary {
createdAt: string createdAt: string
postedAt: string | null postedAt: string | null
lineCount: number lineCount: number
paidAmount: number
balanceAmount: number
} }
/** docs/11 §4.2 confirm response. */ /** docs/11 §4.2 confirm response. */
@@ -132,6 +138,8 @@ export interface ConfirmGrnResponse {
grnId: number grnId: number
status: GrnStatus status: GrnStatus
postedAt: string postedAt: string
glJournalNo: string
balanceAmount: number
createdLayers: CreatedLayer[] createdLayers: CreatedLayer[]
ledgerRefs: number[] ledgerRefs: number[]
poStatus: PurchaseOrderStatus | null poStatus: PurchaseOrderStatus | null
@@ -147,3 +155,23 @@ export interface ReleaseGrnLineResponse {
grnLineId: number grnLineId: number
holdStatus: HoldStatus holdStatus: HoldStatus
} }
/** A vendor payment against a confirmed GRN's balance (installments allowed). */
export interface GrnPayment {
grnPaymentId: number
grnId: number
amount: number
paymentDate: string
glBankAccountId: number
bankAccountName: string
reference: string | null
/** Journal number of the real GL journal entry this payment posted. */
glJournalNo: string | null
createdAt: string
}
export interface CreateGrnPaymentRequest {
amount: number
glBankAccountId: number
reference?: string | null
}