CoolCMS API

CreditNoteLedger
in package

FinalYes

Remaining balances for partial credit notes.

Holds the single interpretation of the invariant that for every line of the source invoice the sum of credited pieces is at most the invoiced pieces. Full-order and partial credit notes both pass through this sum, otherwise one path would bypass the cap of the other.

  • The remainder is recomputed on every call, never kept as a counter (a counter drifts after the first deleted document). The source of truth is the existing credit notes (Invoice::relatedInvoiceId) and their related_*_id lines.
  • The key is the id of the source invoice line, never order_item_id: that one is not unique, because item fees get their own invoice line with the same order_item_id.
  • Vouchers are keyed by amount, not pieces. A voucher on an e-shop invoice is always a fixed amount (percents = null), so a partial credit note must prorate it and the ledger sums how much was already dissolved. A percentage voucher (manual invoices only) dissolves itself by copying percents.
  • unattributed counts credit note lines without related_*_id (old documents the backfill could not pair). The cap cannot be trusted for such an invoice, so callers must reject it, not ignore it.
  • Quantities are always whole pieces. InvoiceItem::quantity is decimal(8,2), but the ledger, violations() and credit note line mirroring work with int. A fractional quantity is never truncated ((int) '2.50' would lock the remaining 0.5 forever, and 0.50 would drop the line from the ledger); it throws instead. Credit notes over fractional quantities are manual work.

The ledger takes no lock: the caller must run the cap check and the credit note write inside acquireSourceLock().

Reads are live: InvoiceService is not in the wire-payload query cache allowlist (cache.config.enabledServices), so the ledger always hits the database. IMPORTANT: the cap is a monetary check; re-verify that assumption before enabling the query cache for Invoicer, otherwise a stale ledger lets a line be credited twice.

Tags
phpstan-type

TLedgerLine array{invoiced: int, credited: int, remaining: int}

TLedgerVoucher array{invoiced: numeric-string, credited: numeric-string, remaining: numeric-string}

TCreditNoteLedger array{ sourceInvoiceId: int, creditNoteIds: list, items: array<int, TLedgerLine>, shippers: array<int, TLedgerLine>, paymentMethods: array<int, TLedgerLine>, vouchers: array<int, TLedgerVoucher>, unattributed: array{items: int, shippers: int, paymentMethods: int, vouchers: int}, }

Table of Contents

Constants

COLLECTION_ITEMS  : string = 'items'
Collections that carry a quantity; they have a per-piece cap.
COLLECTION_PAYMENT_METHODS  : string = 'paymentMethods'
COLLECTION_SHIPPERS  : string = 'shippers'
LINE_INCLUDES  : array<string|int, mixed> = ['items', 'shippers', 'paymentMethods', 'vouche...
MONEY_SCALE  : int = 3
Scale of the Invoicer `decimal(10,3)` columns, same as `OrderInvoiceGenerator`.
QUANTITY_COLLECTIONS  : array<string|int, mixed> = [self::COLLECTION_ITEMS, self::COLLECTION_SHIPP...

Properties

$invoiceManager  : InvoiceManager

Methods

__construct()  : mixed
forSource()  : TCreditNoteLedger
Builds the ledger from an already loaded source invoice, saving a query for callers that hold it (see {@see \App\UI\Api\Eshop\Models\Generators\OrderInvoiceGenerator}).
forSourceInvoiceId()  : TCreditNoteLedger|null
Builds the ledger by source invoice id; returns null when the invoice does not exist.
hasRemainder()  : bool
Tells whether anything is left to credit on the source invoice (the UI offers the button only then).
hasUnattributedCreditLines()  : bool
Tells whether the source has a credit note line that cannot be attributed.
violations()  : array<int, array{collection: string, sourceLineId: int, requested: int, remaining: int}>
Returns the cap violations of the requested partial credit note; an empty array means it is fine.
creditedQuantities()  : array{credited: array, unattributed: int}
Returns the quantity already credited by the lines of one credit note, keyed by source line id.
creditedVoucherAmounts()  : array{credited: array, unattributed: int}
Returns the fixed amount already dissolved by the vouchers of one credit note.
creditNotesOf()  : array<int, Invoice>
Returns the credit notes of the source invoice.
invoicedQuantities()  : array<int, TLedgerLine>
Returns the invoiced quantity per source line.
invoicedVoucherAmounts()  : array<int, TLedgerVoucher>
Returns the fixed discount amount per source voucher.
money()  : numeric-string
relatedLineId()  : int|null
wholeQuantity()  : int
Returns the line quantity as whole pieces and fails loudly otherwise.
withRemainders()  : TCreditNoteLedger
Computes `remaining`, clamped to 0 so an overdrawn line (corrupt data) cannot lend a negative balance; the cap just closes.

Constants

COLLECTION_ITEMS

Collections that carry a quantity; they have a per-piece cap.

public string COLLECTION_ITEMS = 'items'

COLLECTION_PAYMENT_METHODS

public string COLLECTION_PAYMENT_METHODS = 'paymentMethods'

LINE_INCLUDES

private array<string|int, mixed> LINE_INCLUDES = ['items', 'shippers', 'paymentMethods', 'vouchers']

MONEY_SCALE

Scale of the Invoicer `decimal(10,3)` columns, same as `OrderInvoiceGenerator`.

private int MONEY_SCALE = 3

QUANTITY_COLLECTIONS

private array<string|int, mixed> QUANTITY_COLLECTIONS = [self::COLLECTION_ITEMS, self::COLLECTION_SHIPPERS, self::COLLECTION_PAYMENT_METHODS]

Properties

Methods

forSource()

Builds the ledger from an already loaded source invoice, saving a query for callers that hold it (see {@see \App\UI\Api\Eshop\Models\Generators\OrderInvoiceGenerator}).

public forSource(Invoice $source) : TCreditNoteLedger

IMPORTANT: the invoice must be loaded with the includes LINE_INCLUDES. Otherwise lazy empty collections look like nothing was invoiced and the cap lets everything through.

Parameters
$source : Invoice
Tags
throws
DecimalQuantityNotSupportedException

when a line has a fractional quantity, see wholeQuantity()

Return values
TCreditNoteLedger

forSourceInvoiceId()

Builds the ledger by source invoice id; returns null when the invoice does not exist.

public forSourceInvoiceId(int $sourceInvoiceId) : TCreditNoteLedger|null
Parameters
$sourceInvoiceId : int
Tags
throws
DecimalQuantityNotSupportedException

when a line has a fractional quantity, see wholeQuantity()

Return values
TCreditNoteLedger|null

hasRemainder()

Tells whether anything is left to credit on the source invoice (the UI offers the button only then).

public hasRemainder(TCreditNoteLedger $ledger) : bool
Parameters
$ledger : TCreditNoteLedger
Return values
bool

hasUnattributedCreditLines()

Tells whether the source has a credit note line that cannot be attributed.

public hasUnattributedCreditLines(TCreditNoteLedger $ledger) : bool

The cap does not hold then and a partial credit note must be rejected; only a person can pair it.

Parameters
$ledger : TCreditNoteLedger
Return values
bool

violations()

Returns the cap violations of the requested partial credit note; an empty array means it is fine.

public violations(TCreditNoteLedger $ledger, array<string, array<int, int>> $requested) : array<int, array{collection: string, sourceLineId: int, requested: int, remaining: int}>

This is the only place where the cap is evaluated. Call it inside the source lock.

Parameters
$ledger : TCreditNoteLedger
$requested : array<string, array<int, int>>

collection => (source line id => pieces)

Return values
array<int, array{collection: string, sourceLineId: int, requested: int, remaining: int}>

creditedQuantities()

Returns the quantity already credited by the lines of one credit note, keyed by source line id.

private creditedQuantities(iterable<string|int, mixed> $lines) : array{credited: array, unattributed: int}

The quantity on a credit note is positive (quantity is mirrored, the price is negated; see mirrorLine()).

Parameters
$lines : iterable<string|int, mixed>
Tags
throws
DecimalQuantityNotSupportedException

when a credit note line has a fractional quantity

Return values
array{credited: array, unattributed: int}

creditedVoucherAmounts()

Returns the fixed amount already dissolved by the vouchers of one credit note.

private creditedVoucherAmounts(iterable<string|int, mixed> $vouchers) : array{credited: array, unattributed: int}

The price is negated on a credit note, so it is returned as a positive magnitude to sum with the same sign as invoiced.

Parameters
$vouchers : iterable<string|int, mixed>
Return values
array{credited: array, unattributed: int}

creditNotesOf()

Returns the credit notes of the source invoice.

private creditNotesOf(int $sourceInvoiceId) : array<int, Invoice>

There is deliberately no filter on the credit note document type: related_invoice_id is #[ExternalReadonly] and only buildCreditNote() of both generators writes it. A type filter would silently drop a document from the ledger after a seed change, and undercounting lets a line be credited twice. Counting extra is safe (it only rejects more).

Parameters
$sourceInvoiceId : int
Return values
array<int, Invoice>

invoicedQuantities()

Returns the invoiced quantity per source line.

private invoicedQuantities(iterable<string|int, mixed> $lines) : array<int, TLedgerLine>
Parameters
$lines : iterable<string|int, mixed>
Tags
throws
DecimalQuantityNotSupportedException

when a source line has a fractional quantity

Return values
array<int, TLedgerLine>

invoicedVoucherAmounts()

Returns the fixed discount amount per source voucher.

private invoicedVoucherAmounts(iterable<string|int, mixed> $vouchers) : array<int, TLedgerVoucher>

The amount is positive on the document (the calculator subtracts it). A purely percentage voucher has price = null, so its fixed part is 0; its proportion comes from copying percents.

Parameters
$vouchers : iterable<string|int, mixed>
Return values
array<int, TLedgerVoucher>

money()

private money(string|null $value) : numeric-string
Parameters
$value : string|null
Return values
numeric-string

wholeQuantity()

Returns the line quantity as whole pieces and fails loudly otherwise.

private wholeQuantity(InvoiceItem|InvoiceShipper|InvoicePaymentMethod $line, string $origin) : int

A silent (int) cut would give a cap of 2 for 2.5 (the remaining 0.5 locked forever) and drop a 0.5 line from the ledger. Until the ledger supports decimals, throwing is the only safe reaction; a credit note for such an invoice is manual work.

Parameters
$line : InvoiceItem|InvoiceShipper|InvoicePaymentMethod
$origin : string
Tags
throws
DecimalQuantityNotSupportedException
Return values
int

withRemainders()

Computes `remaining`, clamped to 0 so an overdrawn line (corrupt data) cannot lend a negative balance; the cap just closes.

private withRemainders(TCreditNoteLedger $ledger) : TCreditNoteLedger
Parameters
$ledger : TCreditNoteLedger
Return values
TCreditNoteLedger
On this page

Search results