docs(economic): map draft-invoice layout code paths (TRU-198) (#395)

## Summary

Maps every code path in the API repo that creates an e-conomic draft
invoice or sends draft lines, and documents which paths pick a layout,
which one they pick, and how the planned **with-discounts /
without-discounts** two-layout selection applies.

**Key finding:** the two envelope creators already implement a
discount-aware selector. No code change is required for the TRU-197
rollout — only the two `invoice*LayoutNumber` config variables need to
be set in the `economic` module.

## Findings at a glance

- **22** code paths in `services/nginx/app/` create or send draft
invoices (2 envelope creators + 6 line-add paths + 14
caller/selector/helper paths)
- **2** paths currently pick a layout — both already discount-aware
- **0** paths need updating for the 2-layout rollout
- **2** config variables drive the selection: `invoiceLayoutNumber` and
`invoiceDiscountLayoutNumber` (already wired into `economic::$config`
and the OpenAPI schema)

## The two selectors

1. `economic_invoice_draft_mo::resolveLayoutNumber()` at
`services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php:115`
— used by `createInvoiceDraftExample()` for the single-order draft flow.
2. `collected_order_invoices_o::resolveInvoiceLayoutNumber()` at
`services/nginx/app/objects/collected_order_invoices_o.php:673` — used
by `createInvoiceDraft()` for the collected-invoice flow.

Both return `invoice_discount_layout` if any item has a non-zero
discount, otherwise `invoice_layout`. They throw if the discount layout
is required and `invoiceDiscountLayoutNumber` is unconfigured.

## Document

`documentation/economic/layout-selection-flow.md` — full inventory
table, current/desired state, and migration plan.

## Related

- TRU-197 — `documentation/economic/invoice-template-audit.md`
- TRU-193 — `documentation/economic/export-field-audit.md`
- PR #391 — `economic_export_sanitizer`

Refs: TRU-198

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
This commit is contained in:
Jeppe B
2026-08-17 13:05:13 +02:00
committed by GitHub
co-authored by openhands OpenClaw TRU-198 Subagent
parent ea9bdbe12c
commit ae4b7aef07
6 changed files with 1132 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env php8.4
<?php
/**
* Live verification of e-conomic draft invoice creation using customer 12345679.
*
* This script:
* 1. Connects to the real e-conomic API (requires env credentials)
* 2. Creates a draft invoice for customer 12345679 with TEST items
* 3. Verifies the draft was created correctly
* 4. DELETES the draft to clean up
*
* Usage (on production server with credentials):
* php8.4 verify-economic-drafts-live.php
*
* Required env vars (set in .env or pass inline):
* ECONOMIC_API_APP_ACCESS_GRANT
* ECONOMIC_API_APP_SECRET_TOKEN
*
* Optional:
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
*
* Exit codes:
* 0 = all verifications passed, draft cleaned up
* 1 = error during verification
* 2 = cleanup failed (draft still exists, manual intervention required)
*/
declare(strict_types=1);
// 1. Load credentials
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
if (!$grant || !$secret) {
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
exit(1);
}
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
/**
* Send a request to the e-conomic API.
*
* @return array{status: int, body: string, json?: array}
*/
function econ_request(string $method, string $url, ?array $body = null): array
{
global $auth;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
trim(explode("\r\n", $auth)[0]),
trim(explode("\r\n", $auth)[1]),
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
return ['status' => 0, 'body' => $error];
}
$json = json_decode($response, true);
return ['status' => $status, 'body' => $response, 'json' => $json];
}
$draftInvoiceNumber = null;
$pass = 0;
$fail = 0;
$total = 0;
function check(string $name, bool $ok, string $detail = ''): void
{
global $pass, $fail, $total;
$total++;
if ($ok) {
$pass++;
echo "$name\n";
if ($detail) echo " $detail\n";
} else {
$fail++;
echo "$name\n";
if ($detail) echo " $detail\n";
}
}
echo "=== E-conomic Live Draft Verification ===\n";
echo "Customer: $customer\n";
echo "API base: $baseUrl\n\n";
try {
// ------------------------------------------------------------------
// Step 1: Verify customer exists
// ------------------------------------------------------------------
echo "Step 1: Verify customer $customer exists...\n";
$resp = econ_request('GET', "$baseUrl/customers/$customer");
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 200) {
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
exit(1);
}
$customerName = $resp['json']['name'] ?? 'unknown';
echo " Customer name: $customerName\n\n";
// ------------------------------------------------------------------
// Step 2: Create draft invoice
// ------------------------------------------------------------------
echo "Step 2: Create draft invoice for customer $customer...\n";
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
'currency' => 'DKK',
'customer' => ['customerNumber' => $customer],
'paymentTerms' => ['paymentTermsNumber' => 1],
'layout' => ['layoutNumber' => 1],
'recipient' => ['name' => 'OpenClaw Live Verification'],
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
]);
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
if ($resp['status'] !== 201) {
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
echo " Draft invoice number: $draftInvoiceNumber\n\n";
if (!$draftInvoiceNumber) {
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
// ------------------------------------------------------------------
// Step 3: Add test lines to draft
// ------------------------------------------------------------------
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
$lines = [
[
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
'quantity' => 1.0,
'unitNetPrice' => 100.00,
'discountPercentage' => 0.0,
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
],
[
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
'quantity' => 2.0,
'unitNetPrice' => 200.00,
'discountPercentage' => 15.0,
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
],
];
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
'lines' => $lines,
]);
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
// ------------------------------------------------------------------
// Step 4: Verify draft contents
// ------------------------------------------------------------------
echo "\nStep 4: Verify draft contents...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
$draft = $resp['json'] ?? [];
$draftLines = $draft['lines'] ?? [];
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
// ------------------------------------------------------------------
// Step 5: Cleanup - delete the draft
// ------------------------------------------------------------------
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
// ------------------------------------------------------------------
// Step 6: Verify deletion
// ------------------------------------------------------------------
echo "\nStep 6: Verify draft is gone...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
} catch (\Throwable $e) {
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
// Best-effort cleanup
if ($draftInvoiceNumber !== null) {
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
echo " Cleanup HTTP status: {$resp['status']}\n";
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
}
exit(1);
}
echo "\n=== Summary: $pass/$total checks passed ===\n";
exit($fail === 0 ? 0 : 1);