This document details the complete credit note creation process in the rossaddison/invoice system. Understanding this workflow is essential for:
- System maintenance and improvements
- Implementing Peppol e-credit-note functionality
- Troubleshooting credit note issues
- Extending partial credit capabilities
- Architecture Overview
- User Interface Flow
- Backend Processing
- Database Structure
- Reversal Logic
- Peppol Considerations
- Extension Points
- Credit Notes are Invoices: Credit notes use
group_id = 4to differentiate them from regular invoices - Full Reversal Only: The current implementation creates complete reversals of the original invoice
- Three-Stage Reversal: Line items, invoice totals, and tax summaries are reversed independently
- Reverse Relationship: The original invoice stores the credit note ID (not the other way around)
- Backend: PHP (Yii3 Framework)
- Frontend: TypeScript (compiled to IIFE)
- Database: MySQL
- ORM: Cycle ORM
- UI Framework: Bootstrap 5
The "Create Credit Invoice" option appears when ALL conditions are met:
// Location: resources/views/invoice/inv/view.php
if (($readOnly === true || $inv->getStatus_id() === 4)
&& $invEdit
&& !(int) $inv->getCreditinvoice_parent_id() > 0) {
// Show create credit invoice option
}Conditions:
- Invoice is read-only OR status is 4 (Paid)
- User has edit permissions
- Invoice is NOT already a credit note
<a href="#create-credit-inv"
data-bs-toggle="modal"
data-invoice-id="<?= $inv->getId(); ?>"
style="text-decoration:none">
<i class="fa fa-minus fa-margin"></i>
<?= Html::encode($translator->translate('create.credit.invoice')); ?>
</a>// Location: src/Widget/ButtonsToolbarFull
$primaryButtons[] = $this->createModalButton(
'credit', // Button type
'#create-credit-inv', // Modal selector
'fa-minus', // Font Awesome icon
'btn-danger', // Red button styling
$this->translator->translate('create.credit.invoice'),
['data-invoice-id' => $invId],
);Modal View: resources/views/invoice/inv/modal_create_credit
The modal:
- Displays invoice details
- Asks for confirmation
- May collect reason for credit (implementation-specific)
Location: src/typescript/ → compiled to src/Invoice/Asset/rebuild/js/invoice-typescript-iife.js
// Event handler for credit confirmation
$(document).on('click', '#create-credit-confirm', function () {
const invoiceId = $(this).data('invoice-id');
// AJAX submission to backend
$.ajax({
url: '/invoice/inv/create_credit_confirm',
method: 'POST',
data: { inv_id: invoiceId, /* other params */ },
success: function(response) {
if (response.success === 1) {
// Reload page or redirect
location.reload();
}
}
});
});Location: src/Invoice/Inv/InvController.php
public function create_credit_confirm(
Request $request,
FormHydrator $formHydrator,
IR $iR, // Invoice Repository
GR $gR, // Group Repository
IIR $iiR, // Invoice Item Repository
IIAR $iiaR, // Invoice Item Amount Repository
UCR $ucR, // User Client Repository
UIR $uiR, // User Invoice Repository
UR $uR // User Repository
): \Yiisoft\DataResponse\DataResponse$body = $request->getQueryParams();
$basis_inv = $iR->repoInvLoadedquery((string) $body['inv_id']);$basis_inv->setIs_read_only(true);Purpose: Prevents modifications to the original invoice after credit is issued.
$ajax_body = [
'client_id' => $body['client_id'],
'group_id' => 4, // ← CRITICAL: Identifies this as a Credit Note
'user_id' => $body['user_id'],
'status_id' => $basis_inv->getStatus_id(),
'is_read_only' => true, // Credit notes are read-only by default
'number' => $gR->generate_number(4, true), // Auto-generate credit note number
'discount_amount' => $basis_inv->getDiscount_amount(),
'discount_percent' => $basis_inv->getDiscount_percent(),
'url_key' => '',
'password' => $body['password'],
'payment_method' => 0,
'terms' => '',
'delivery_location_id' => $basis_inv->getDelivery_location_id(),
];$new_inv = new Inv();
$form = new InvForm($new_inv);
if ($formHydrator->populateAndValidate($form, $ajax_body)) {
$user = $this->active_user($client_id, $uR, $ucR, $uiR);
$saved_inv = $this->inv_service->saveInv(
$user,
$new_inv,
$ajax_body,
$this->sR,
$gR
);
$saved_inv_id = $saved_inv->getId();
}// Stage 1: Copy and reverse line items
$this->inv_item_service->initializeCreditInvItems(
(int) $basis_inv_id,
$saved_inv_id,
$iiR,
$iiaR,
$this->sR
);
// Stage 2: Copy and reverse invoice totals
$this->inv_amount_service->initializeCreditInvAmount(
new InvAmount(),
(int) $basis_inv_id,
$saved_inv_id
);
// Stage 3: Copy and reverse tax rate summaries
$this->inv_tax_rate_service->initializeCreditInvTaxRate(
(int) $basis_inv_id,
$saved_inv_id
);// IMPORTANT: Original invoice points to credit note
$basis_inv->setCreditinvoice_parent_id((int) $saved_inv_id);
$iR->save($basis_inv);$parameters = [
'success' => 1,
'flash_message' => $this->translator->translate('credit.note.creation.successful'),
];
return $this->factory->createResponse(Json::encode($parameters));┌─────────────────┐
│ inv (Invoice) │
├─────────────────┤
│ id │◄─────┐
│ group_id │ │
│ status_id │ │
│ creditinvoice_ │ │ Reverse relationship:
│ parent_id │──────┘ Original invoice stores
│ is_read_only │ credit note ID
└─────────────────┘
│
│ 1:N
▼
┌─────────────────┐
│ inv_item │
├─────────────────┤
│ id │
│ inv_id │
│ product_id │
│ quantity │◄────── NEGATED for credit notes
│ price │◄────── Stays POSITIVE
│ discount_amount │
└─────────────────┘
│
│ 1:1
▼
┌─────────────────┐
│ inv_item_amount │
├─────────────────┤
│ inv_item_id │
│ subtotal │◄────── ALL reversed (× -1)
│ tax_total │◄────── ALL reversed (× -1)
│ discount │◄────── ALL reversed (× -1)
│ total │◄────── ALL reversed (× -1)
└─────────────────┘
┌─────────────────┐
│ inv_amount │
├─────────────────┤
│ inv_id │
│ item_subtotal │◄────── ALL reversed (× -1)
│ item_tax_total │◄────── ALL reversed (× -1)
│ tax_total │◄────── ALL reversed (× -1)
│ total │◄────── ALL reversed (× -1)
│ paid │◄────── RESET to 0.00
│ balance │◄────── Reversed (× -1)
│ sign │◄────── Set to 1
└─────────────────┘
┌─────────────────┐
│ inv_tax_rate │
├─────────────────┤
│ inv_id │
│ tax_rate_id │◄────── Same as original
│ include_item_tax│◄────── Same as original
│ inv_tax_rate_ │◄────── Reversed (× -1)
│ amount │
└─────────────────┘
| Group ID | Type | Description |
|---|---|---|
| 1 | Quote | Quotation/Estimate |
| 2 | Sales Order | Confirmed order |
| 3 | Invoice | Regular invoice |
| 4 | Credit Note | Credit invoice |
| Status ID | Status | Notes |
|---|---|---|
| 1 | Draft | Not sent to customer |
| 2 | Sent | Sent to customer |
| 3 | Viewed | Customer has viewed |
| 4 | Paid | Common trigger for credit notes |
| 5 | Overdue | Past due date |
Service: InvItemService::initializeCreditInvItems()
Location: src/Invoice/InvItem/InvItemService.php
foreach ($items as $item) {
$new_item = new InvItem();
$new_item->setInv_id((int) $new_inv_id);
$new_item->setTax_rate_id((int) $item->getTax_rate_id());
// Copy product or task reference
null !== $item->getProduct_id()
? $new_item->setProduct_id((int) $item->getProduct_id())
: $new_item->setTask_id((int) $item->getTask_id());
// Copy descriptions
$new_item->setName($item->getName() ?? '');
$new_item->setDescription($item->getDescription() ?? '');
$new_item->setNote($item->getNote() ?? '');
// ⚡ KEY REVERSAL: Negate quantity
$new_item->setQuantity(($item->getQuantity() ?? 1.00) * -1.00);
// Keep prices and discounts positive
$new_item->setPrice($item->getPrice() ?? 0.00);
$new_item->setDiscount_amount($item->getDiscount_amount() ?? 0.00);
// Other fields
$new_item->setOrder(0);
$new_item->setIs_recurring($item->getIs_recurring() ?? false);
$new_item->setProduct_unit($item->getProduct_unit() ?? '');
$new_item->setProduct_unit_id((int) $item->getProduct_unit_id());
$new_item->setDate($item->getDate_added());
$iiR->save($new_item);
}// Get calculated amounts from original
$basis_item_amount = $iiaR->repoInvItemAmountquery((string) $item->getId());
if ($basis_item_amount) {
$new_item_amount = new InvItemAmount();
$new_item_amount->setInv_item_id((int) $new_item->getId());
// Reverse all calculated amounts
$new_item_amount->setSubtotal(
($basis_item_amount->getSubtotal() ?? 0.00) * -1.00
);
$new_item_amount->setTax_total(
($basis_item_amount->getTax_total() ?? 0.00) * -1.00
);
$new_item_amount->setDiscount(
($basis_item_amount->getDiscount() ?? 0.00) * -1.00
);
$new_item_amount->setTotal(
($basis_item_amount->getTotal() ?? 0.00) * -1.00
);
$iiaR->save($new_item_amount);
}Original Line Item:
Product: Widget A
Quantity: 5
Price: $100.00
Discount: $10.00
─────────────────
Subtotal: $500.00
Discount: $50.00
Taxable: $450.00
Tax (20%): $90.00
Total: $540.00
Credit Note Line Item:
Product: Widget A
Quantity: -5 ← NEGATED
Price: $100.00 ← SAME
Discount: $10.00 ← SAME
─────────────────
Subtotal: -$500.00 ← REVERSED
Discount: -$50.00 ← REVERSED
Taxable: -$450.00 ← REVERSED
Tax (20%): -$90.00 ← REVERSED
Total: -$540.00 ← REVERSED
Service: InvAmountService::initializeCreditInvAmount()
Location: src/Invoice/InvAmount/InvAmountService.php
public function initializeCreditInvAmount(
InvAmount $model,
int $basis_inv_id,
string $new_inv_id
): void {
$basis_invoice = $this->repository->repoInvquery($basis_inv_id);
$model->setInv_id((int) $new_inv_id);
$model->setSign(1);
// Reverse all invoice-level totals
$model->setItem_subtotal(
($basis_invoice->getItem_subtotal() ?: 0.00) * -1.00
);
$model->setItem_tax_total(
($basis_invoice->getItem_tax_total() ?: 0.00) * -1.00
);
$model->setPackhandleship_total(
($basis_invoice->getPackhandleship_total() ?: 0.00) * -1.00
);
$model->setPackhandleship_tax(
($basis_invoice->getPackhandleship_tax() ?: 0.00) * -1.00
);
$model->setTax_total(
($basis_invoice->getTax_total() ?? 0.00) * -1.00
);
$model->setTotal(
($basis_invoice->getTotal() ?? 0.00) * -1.00
);
// ⚡ CRITICAL: Reset paid to zero
$model->setPaid(0.00);
$model->setBalance(
($basis_invoice->getBalance() ?? 0.00) * -1.00
);
$this->repository->save($model);
}Original Invoice Totals:
Item Subtotal: $1,000.00
Item Tax Total: $200.00
Shipping Total: $50.00
Shipping Tax: $5.00
──────────────────────────────────
Tax Total: $205.00
Grand Total: $1,255.00
Paid: $1,255.00 ✅
Balance: $0.00
Credit Note Totals:
Item Subtotal: -$1,000.00
Item Tax Total: -$200.00
Shipping Total: -$50.00
Shipping Tax: -$5.00
──────────────────────────────────
Tax Total: -$205.00
Grand Total: -$1,255.00
Paid: $0.00 ⚠️ RESET
Balance: -$1,255.00 ← Owed to customer
Service: InvTaxRateService::initializeCreditInvTaxRate()
Location: src/Invoice/InvTaxRate/InvTaxRateService.php
public function initializeCreditInvTaxRate(
int $basis_inv_id,
?string $new_inv_id
): void {
$basis_invoice_tax_rates =
$this->repository->repoInvquery((string) $basis_inv_id);
foreach ($basis_invoice_tax_rates as $basis_invoice_tax_rate) {
$new_invoice_tax_rate = new InvTaxRate();
$new_invoice_tax_rate->setInv_id((int) $new_inv_id);
$new_invoice_tax_rate->setTax_rate_id(
(int) $basis_invoice_tax_rate->getTax_rate_id()
);
// Copy tax inclusion setting
if ($basis_invoice_tax_rate->getInclude_item_tax() == 1
|| $basis_invoice_tax_rate->getInclude_item_tax() == 0) {
$new_invoice_tax_rate->setInclude_item_tax(
$basis_invoice_tax_rate->getInclude_item_tax() ?? 0
);
}
// Reverse the tax amount
$new_invoice_tax_rate->setInv_tax_rate_amount(
($basis_invoice_tax_rate->getInv_tax_rate_amount() ?? 0.00) * -1.00
);
$this->repository->save($new_invoice_tax_rate);
}
}The inv_tax_rate table stores tax summaries for:
- Tax compliance reporting
- VAT returns
- Tax authority submissions
- Financial reports
| Value | Meaning | Example |
|---|---|---|
| 1 | Item-level tax | VAT, Sales Tax on products |
| 0 | Invoice-level tax | Special levies, environmental charges |
Original Tax Breakdown:
Tax Rate 1 (VAT 20%): $200.00 [Include Item Tax: 1]
Tax Rate 2 (State 5%): $50.00 [Include Item Tax: 1]
─────────────────────────────────
Total Tax: $250.00
Credit Note Tax Breakdown:
Tax Rate 1 (VAT 20%): -$200.00 [Include Item Tax: 1]
Tax Rate 2 (State 5%): -$50.00 [Include Item Tax: 1]
─────────────────────────────────
Total Tax: -$250.00
Customer purchased:
- 5 × Widget A @ $100 each
- 10 × Widget B @ $50 each
- VAT at 20%
- Shipping: $25
Payment received in full. Customer now requests full credit.
Invoice #INV-001234
Status: Paid
Group: Invoice (3)
Line Items:
┌────────────┬──────────┬─────────┬───────────┬─────────┬─────────┐
│ Product │ Quantity │ Price │ Subtotal │ Tax 20% │ Total │
├────────────┼──────────┼─────────┼───────────┼─────────┼─────────┤
│ Widget A │ 5 │ $100.00 │ $500.00 │ $100.00 │ $600.00 │
│ Widget B │ 10 │ $50.00 │ $500.00 │ $100.00 │ $600.00 │
└────────────┴──────────┴─────────┴───────────┴─────────┴─────────┘
Invoice Totals:
Item Subtotal: $1,000.00
Item Tax (20%): $200.00
Shipping: $25.00
Shipping Tax (20%): $5.00
───────────────────────────────
Total Tax: $205.00
Grand Total: $1,230.00
Paid: $1,230.00
Balance: $0.00
Tax Summary:
VAT 20%: $205.00
Credit Note #CN-000567
Status: Paid (inherited)
Group: Credit Note (4)
Parent Invoice: INV-001234 (via creditinvoice_parent_id)
Line Items:
┌────────────┬──────────┬─────────┬───────────┬─────────┬──────────┐
│ Product │ Quantity │ Price │ Subtotal │ Tax 20% │ Total │
├────────────┼──────────┼─────────┼───────────┼─────────┼──────────┤
│ Widget A │ -5 │ $100.00 │ -$500.00 │-$100.00 │ -$600.00 │
│ Widget B │ -10 │ $50.00 │ -$500.00 │-$100.00 │ -$600.00 │
└────────────┴──────────┴─────────┴───────────┴─────────┴──────────┘
Invoice Totals:
Item Subtotal: -$1,000.00
Item Tax (20%): -$200.00
Shipping: -$25.00
Shipping Tax (20%): -$5.00
───────────────────────────────
Total Tax: -$205.00
Grand Total: -$1,230.00
Paid: $0.00 ⚠️
Balance: -$1,230.00 ← Customer credit
Tax Summary:
VAT 20%: -$205.00
Customer Account Balance:
Original Invoice: +$1,230.00 (charged)
Credit Note: -$1,230.00 (credited)
─────────────────────────────────
Net Balance: $0.00
When implementing Peppol e-credit-notes, the following must be considered:
Current Implementation:
- Uses
group_id = 4internally
Peppol Requirement:
- UBL Invoice document type code:
381(Credit Note) or384(Corrected Invoice)
Implementation Location:
// Likely in: src/Invoice/Helpers/Peppol/PeppolArrays.php or similar
// When generating UBL XML:
if ($invoice->getGroup_id() === 4) {
$documentTypeCode = '381'; // Credit Note
}Peppol Requirement:
- Credit notes MUST reference the original invoice using
<cac:BillingReference>
Implementation:
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>INV-001234</cbc:ID>
<cbc:IssueDate>2025-01-15</cbc:IssueDate>
</cac:InvoiceDocumentReference>
</cac:BillingReference>Data Source:
// Get original invoice via creditinvoice_parent_id
$originalInvoice = $iR->repoInvLoadedquery(
(string) $creditNote->getCreditinvoice_parent_id()
);Current Implementation: ✅ Already compliant
- Quantities are negative
- Prices remain positive
- Calculation produces negative amounts
Peppol Requirement:
- Line items should show negative
InvoicedQuantity LineExtensionAmountshould be negative- Tax amounts should be negative
Peppol Optional Element:
<cbc:CreditNoteTypeCode>380</cbc:CreditNoteTypeCode>
<cbc:Note>Full refund - customer cancellation</cbc:Note>Enhancement Needed:
- Add
credit_reasonfield to modal - Store in database (could use
inv.termsor new field) - Include in UBL generation
Current Implementation: ✅ Tax rates preserved
inv_tax_ratemaintains tax rate references- Amounts are properly negated
Peppol Requirement:
- Tax category codes must match original invoice
- Negative tax amounts are acceptable
Peppol Guidance:
- Credit notes typically don't include
<cac:PaymentMeans> - If refund method is specified, it should be clear
Current Implementation:
'payment_method' => 0, // No payment method for credit notes-
Peppol Generator Service
- Location:
src/Invoice/Helpers/Peppol/PeppolService.php(or similar) - Add credit note detection
- Generate appropriate UBL structure
- Location:
-
UBL Array Builders
- Location:
src/Invoice/Helpers/Peppol/PeppolArrays.php(or similar) - Build
<cac:BillingReference>for credit notes - Set correct document type codes
- Location:
-
XML Template
- May use PHP array → XML conversion
- Ensure negative amounts render correctly
- Include credit-specific elements
// In PeppolService or similar
public function generateCreditNoteUBL(Inv $creditNote, Inv $originalInvoice): string
{
$ublArray = [
'Invoice' => [
'@xmlns' => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'@xmlns:cac' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'@xmlns:cbc' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
'cbc:CustomizationID' => 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'cbc:ProfileID' => 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
// Credit Note specific
'cbc:ID' => $creditNote->getNumber(),
'cbc:IssueDate' => $creditNote->getDate_created(),
'cbc:InvoiceTypeCode' => '381', // Credit Note
// Reference to original invoice
'cac:BillingReference' => [
'cac:InvoiceDocumentReference' => [
'cbc:ID' => $originalInvoice->getNumber(),
'cbc:IssueDate' => $originalInvoice->getDate_created(),
]
],
// ... rest of UBL structure
]
];
return $this->arrayToXml($ublArray);
}Credit notes must pass:
- Schematron Validation (EN 16931 rules)
- Peppol BIS Rules (PEPPOL-EN16931-* rules)
- Country-Specific Extensions (if applicable)
Testing Tools:
- Ecosio Validation - Mentioned in README as tested
- Peppol Testbed
- OpenPeppol Validator
To allow crediting only some items or partial quantities:
// resources/views/invoice/inv/modal_create_credit.php
// Add checkboxes for line item selection
foreach ($items as $item) {
echo '<input type="checkbox" name="items[]" value="' . $item->getId() . '" checked>';
echo '<input type="number" name="qty[' . $item->getId() . ']"
value="' . $item->getQuantity() . '"
max="' . $item->getQuantity() . '">';
}// InvItemService::initializeCreditInvItems()
public function initializeCreditInvItems(
int $basis_inv_id,
string $new_inv_id,
array $selectedItems, // NEW: Selected item IDs
array $quantities, // NEW: Quantities to credit
// ... other params
): void {
foreach ($selectedItems as $itemId) {
$item = $iiR->repoInvItemquery($itemId);
$creditQuantity = $quantities[$itemId] ?? $item->getQuantity();
// Create credit item with specified quantity
$new_item->setQuantity($creditQuantity * -1.00);
// ... rest of logic
}
}ALTER TABLE inv ADD COLUMN credit_reason VARCHAR(255) NULL;
ALTER TABLE inv ADD COLUMN credit_reason_code VARCHAR(10) NULL;// modal_create_credit.php
<select name="credit_reason_code">
<option value="DAMAGED">Damaged goods</option>
<option value="WRONG">Wrong item shipped</option>
<option value="CANCEL">Order cancellation</option>
<option value="RETURN">Customer return</option>
<option value="OTHER">Other</option>
</select>
<textarea name="credit_reason"></textarea>// src/Invoice/Entity/Inv.php
private ?string $credit_reason = null;
private ?string $credit_reason_code = null;
// Add getters and settersFor businesses requiring approval before issuing credits:
// Add new status: "Pending Credit Approval"
// Status ID: 6 (example)
// InvController::create_credit_confirm()
$ajax_body = [
// ...
'status_id' => 6, // Pending approval instead of copying original status
];// InvController::approve_credit()
public function approve_credit(Request $request, IR $iR): Response
{
$creditNoteId = $request->getAttribute('id');
$creditNote = $iR->repoInvLoadedquery($creditNoteId);
// Verify it's a credit note and pending
if ($creditNote->getGroup_id() === 4
&& $creditNote->getStatus_id() === 6) {
// Approve it
$creditNote->setStatus_id(2); // Sent/Approved
$iR->save($creditNote);
// Optionally send to customer
// ...
}
}Currently, credit notes are created but not automatically applied to customer balance.
// Add to Customer entity or create CustomerBalance entity
class CustomerBalance
{
private ?int $client_id = null;
private float $total_invoiced = 0.00;
private float $total_paid = 0.00;
private float $total_credited = 0.00;
public function getBalance(): float
{
return $this->total_invoiced - $this->total_paid - $this->total_credited;
}
}// InvController::create_credit_confirm()
// After creating credit note:
$customerBalance = $customerBalanceRepo->findByClient($client_id);
$customerBalance->setTotal_credited(
$customerBalance->getTotal_credited() + abs($saved_inv->getTotal())
);
$customerBalanceRepo->save($customerBalance);To allow canceling/reversing a credit note:
// InvController::reverse_credit()
public function reverse_credit(Request $request, IR $iR): Response
{
$creditNoteId = $request->getAttribute('id');
$creditNote = $iR->repoInvLoadedquery($creditNoteId);
// Verify it's a credit note
if ($creditNote->getGroup_id() === 4) {
// Option 1: Delete the credit note
// $iR->delete($creditNote);
// Option 2: Mark as void
$creditNote->setStatus_id(7); // Void status
$iR->save($creditNote);
// Reset original invoice
$originalInvoiceId = $creditNote->getCreditinvoice_parent_id();
if ($originalInvoiceId) {
$originalInvoice = $iR->repoInvLoadedquery($originalInvoiceId);
$originalInvoice->setCreditinvoice_parent_id(null);
$originalInvoice->setIs_read_only(false);
$iR->save($originalInvoice);
}
}
}For international businesses:
// Ensure exchange rates are preserved
$new_inv->setCurrency_id($basis_inv->getCurrency_id());
$new_inv->setExchange_rate($basis_inv->getExchange_rate());
// Store original currency amounts for Peppol
// UBL requires DocumentCurrencyCode and may include TaxCurrencyCode-- Include credit notes in sales reports
SELECT
inv.number,
inv.group_id,
CASE
WHEN inv.group_id = 4 THEN 'Credit Note'
ELSE 'Invoice'
END as type,
inv_amount.total,
inv.date_created
FROM inv
LEFT JOIN inv_amount ON inv.id = inv_amount.inv_id
WHERE inv.group_id IN (3, 4) -- Invoices and Credit Notes
ORDER BY inv.date_created DESC;// Show credit note statistics
$creditNotesThisMonth = $iR->countByGroupAndDateRange(4, $startDate, $endDate);
$totalCreditedAmount = $iR->sumTotalByGroupAndDateRange(4, $startDate, $endDate);- Test credit note creation from paid invoice
- Test credit note creation from read-only invoice
- Verify original invoice becomes read-only
- Verify quantities are negated
- Verify prices remain positive
- Verify all amounts are reversed
- Test tax rate reversal
- Test with multiple tax rates
- Test with shipping charges
- Test with discounts
- Full workflow from UI to database
- Verify AJAX response structure
- Test with different invoice statuses
- Test permission checks
- Verify credit note number generation
- Test relationship linking (creditinvoice_parent_id)
- Generate UBL XML for credit note
- Validate against EN 16931 Schematron rules
- Validate against Peppol BIS 3.0 rules
- Test with Ecosio validator
- Verify BillingReference element
- Verify negative amounts in XML
- Test with different tax categories
- Test multi-currency scenarios
- Credit note from credit note (should be prevented)
- Credit note from draft invoice (should be prevented)
- Very large quantities/amounts
- Zero-amount invoices
- Invoices with only shipping
- Multiple discounts
- Rounding edge cases
Symptoms:
- Button appears but nothing happens
- AJAX call fails
- No error message shown
Debugging Steps:
- Check browser console for JavaScript errors
- Verify AJAX endpoint is correct:
/invoice/inv/create_credit_confirm - Check server logs for PHP errors
- Verify user has edit permissions
- Confirm original invoice is not already credited
Solution:
// Add error handling to TypeScript
$.ajax({
// ...
error: function(xhr, status, error) {
console.error('Credit note creation failed:', error);
alert('Failed to create credit note: ' + xhr.responseText);
}
});Symptoms:
- Credit note totals are positive
- Taxes not calculated correctly
Debugging Steps:
- Check if item amounts were saved:
SELECT * FROM inv_item_amount WHERE inv_item_id = ? - Verify multiplication by -1.00:
SELECT quantity FROM inv_item WHERE inv_id = ? - Check for null coalescing issues:
?? 0.00
Solution: Ensure all three initialization methods completed:
// Add logging
Log::info('Credit items initialized', ['inv_id' => $saved_inv_id]);
Log::info('Credit amounts initialized', ['inv_id' => $saved_inv_id]);
Log::info('Credit tax rates initialized', ['inv_id' => $saved_inv_id]);Symptoms:
creditinvoice_parent_idis null or wrong- Can create multiple credits from same invoice
Debugging Steps:
- Check if original invoice was saved:
SELECT creditinvoice_parent_id FROM inv WHERE id = ? - Verify the ID is being set correctly in controller
Solution:
// Add validation
if ($basis_inv->getCreditinvoice_parent_id() > 0) {
throw new \Exception('Invoice already has a credit note');
}Symptoms:
- Schematron errors
- Missing required elements
- Invalid document type
Common Validation Errors:
- BR-CO-15: Missing BillingReference
- BR-16: Negative amounts not properly formatted
- BR-52: Tax category mismatch
Solution:
// Ensure proper UBL structure
$ublArray = [
'cbc:InvoiceTypeCode' => '381', // Must be 381 for credit note
'cac:BillingReference' => [ // REQUIRED for credit notes
'cac:InvoiceDocumentReference' => [
'cbc:ID' => $originalInvoice->getNumber(),
]
],
// Negative amounts should be:
'cbc:InvoicedQuantity' => [
'@unitCode' => 'C62',
'#' => '-5' // Negative as string
],
];The current implementation makes multiple queries:
- Load original invoice: 1 query
- Load invoice items: 1 query
- For each item, load item amount: N queries
- Load tax rates: 1 query
- Multiple save operations: 3N + 3 queries
Optimization Opportunities:
// Batch load item amounts
$itemIds = array_map(fn($item) => $item->getId(), $items);
$itemAmounts = $iiaR->findByItemIds($itemIds);
// Index by item ID for O(1) lookup
$amountsByItemId = [];
foreach ($itemAmounts as $amount) {
$amountsByItemId[$amount->getInv_item_id()] = $amount;
}Ensure atomicity:
// InvController::create_credit_confirm()
DB::beginTransaction();
try {
// All credit note creation logic
DB::commit();
} catch (\Exception $e) {
DB::rollback();
// Return error
}Current Implementation:
if (($readOnly === true || $inv->getStatus_id() === 4)
&& $invEdit
&& !(int) $inv->getCreditinvoice_parent_id() > 0)Enhancement:
// Add explicit permission check
if (!$this->userCan('create_credit_note')) {
throw new ForbiddenException();
}
// Add audit logging
$this->auditLog->log('credit_note_created', [
'user_id' => $currentUser->getId(),
'original_invoice_id' => $basis_inv_id,
'credit_note_id' => $saved_inv_id,
'amount' => abs($saved_inv->getTotal()),
]);Ensure CSRF tokens are validated:
// In form
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
// In controller
if (!$request->getCsrfToken()->validate()) {
throw new BadRequestException('Invalid CSRF token');
}// Validate invoice ID exists and user has access
$basis_inv = $iR->repoInvLoadedquery((string) $body['inv_id']);
if (!$basis_inv) {
throw new NotFoundException('Invoice not found');
}
// Verify user owns this invoice or has permission
if ($basis_inv->getClient_id() !== $currentUser->getClient_id()
&& !$currentUser->isAdmin()) {
throw new ForbiddenException();
}| File | Purpose |
|---|---|
src/Invoice/Inv/InvController.php |
Main controller with create_credit_confirm() |
src/Invoice/InvItem/InvItemService.php |
Line item reversal logic |
src/Invoice/InvAmount/InvAmountService.php |
Invoice total reversal logic |
src/Invoice/InvTaxRate/InvTaxRateService.php |
Tax rate reversal logic |
src/Invoice/Entity/Inv.php |
Invoice entity with creditinvoice_parent_id |
src/Invoice/Entity/InvItem.php |
Line item entity |
src/Invoice/Entity/InvAmount.php |
Invoice amount entity |
src/Invoice/Entity/InvTaxRate.php |
Tax rate summary entity |
src/Invoice/Inv/InvRepository.php |
Invoice database operations |
| File | Purpose |
|---|---|
resources/views/invoice/inv/view.php |
Invoice view with credit option |
resources/views/invoice/inv/modal_create_credit.php |
Credit confirmation modal |
src/Widget/ButtonsToolbarFull.php |
Toolbar button widget |
src/typescript/*.ts |
TypeScript source (compiled to IIFE) |
src/Invoice/Asset/rebuild/js/invoice-typescript-iife.js |
Compiled JavaScript |
| File | Purpose |
|---|---|
src/Invoice/Helpers/Peppol/PeppolService.php |
UBL generation service (likely) |
src/Invoice/Helpers/Peppol/PeppolArrays.php |
UBL array builders (likely) |
| Term | Definition |
|---|---|
| Basis Invoice | The original invoice from which a credit note is created |
| Credit Note | A negative invoice that reverses all or part of an original invoice |
| Group ID | Invoice type identifier (4 = Credit Note) |
| UBL | Universal Business Language - XML standard for e-invoicing |
| Peppol | Pan-European Public Procurement Online - e-invoicing network |
| BIS | Business Interoperability Specifications - Peppol's business rules |
| EN 16931 | European standard for electronic invoicing |
| Invoice Type Code | UBL code identifying document type (381 = Credit Note) |
| BillingReference | UBL element linking credit note to original invoice |
| creditinvoice_parent_id | Database field storing credit note ID in original invoice |
| IIFE | Immediately Invoked Function Expression - JavaScript pattern |
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2025-01-22 | Initial documentation based on current implementation |
When modifying the credit note workflow:
- Update this documentation
- Add unit tests for new functionality
- Update Peppol XML generation if applicable
- Test with actual Peppol validators
- Consider backward compatibility
- Update database migrations if schema changes
- Main README:
/README.md - Peppol Integration Docs:
/docs/PEPPOL_INTEGRATION.md(if exists) - Database Schema: Check migration files
Document Maintainer: Development Team
Last Updated: 2025-01-22
Next Review: When implementing Peppol e-credit-note functionality