Files
erp/test/sandboxCurrencySetup.ts
arcodangeandClaude Opus 5 dbe4b36c62 feat(write-skill): chronology guard + explicit production opt-in
Two guards, both from real incidents in the same session.

1. invoice-create.sh — chronology (CGI art. 289). Dolibarr assigns the number at
   validation, in creation order, so issuing a document dated BEFORE the last one
   already issued gives a higher number to an earlier date. The July plan walked
   straight into it: the M3 deferred part is due 2026-10-23 and must be issued at
   D-60 (24/08) to stay under the L.441-10 I ceiling, while the M4 fixed part is
   dated 23/08 — issue them in the wrong order and the numbering breaks. The
   guard reads the last issued document of the same kind and refuses an earlier
   date, with ARCO_ALLOW_BACKDATE as a loud, documented override.
   Verified: refuses a 01/07 invoice against FAC008 (23/07), accepts 23/08.

2. test/scripts/guard.ts — production opt-in. The sandbox-only guard had no way
   to express a deliberate production run, so any prod work meant bypassing it
   entirely (which is how guards die). Production now requires BOTH
   ARCO_ALLOW_PRODUCTION=<exact host> and
   ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD, and prints a banner. Nothing
   reaches prod by inheriting an ambient variable.
   Also fixes a misleading "(sandbox verified)" log that printed even on prod.

grantAgentRight.ts joins the repo (it was never committed) and gains --revoke,
so a temporarily elevated right can be handed back — used today to attach a
payment in production and revoked immediately after.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-07-26 01:26:12 +02:00

103 lines
3.9 KiB
TypeScript

/*
Register the USD reference rate in the multi-currency module (sandbox only).
Enabling the module is not enough: an invoice can only carry a currency that
exists in the module's rate table, otherwise Dolibarr silently falls back to
EUR (observed: POST /invoices with multicurrency_code=USD returned EUR).
The rate is the ECB EUR/USD reference of the day the invoice is settled —
per the KM amendment, art. 2/4. It is written here so the invoice carries the
contractual obligation ($2,500 / $3,000) rather than a drifting EUR value.
The invoice then carries the obligation itself, not a counter-value.
Run:
DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \
deno run -A test/sandboxCurrencySetup.ts --code USD --rate 1.14416
*/
import "load_dotenv";
import { chromium } from "playwright";
import login from "./scripts/login.ts";
import { assertSandbox } from "./scripts/guard.ts";
const argv = Deno.args;
const pick = (flag: string, dflt: string) =>
argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : dflt;
const code = pick("--code", "USD");
const rate = pick("--rate", "1.14416");
const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp";
const dolibarrAddress = assertSandbox();
console.log(`target: ${dolibarrAddress} (guard passed)`);
const adminCredentials = {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
try {
await login.doAdminLogin({ page, dolibarrAddress, adminCredentials });
// The module's admin page moved across Dolibarr versions — probe the known
// paths and report which one answers instead of assuming.
const candidates = [
"/multicurrency/admin/multicurrency.php",
"/admin/multicurrency.php",
"/multicurrency/admin/setup.php",
];
let adminUrl = "";
for (const p of candidates) {
const resp = await page.goto(`${dolibarrAddress}${p}`, {
waitUntil: "domcontentloaded",
});
const status = resp?.status() ?? 0;
const isError = await page.locator("text=/Forbidden|not found|erreur/i")
.count();
console.log(`probe ${p} -> HTTP ${status}${isError ? " (error page)" : ""}`);
if (status === 200 && !isError) {
adminUrl = `${dolibarrAddress}${p}`;
break;
}
}
if (!adminUrl) {
console.warn("no multicurrency admin page found — reporting only");
await page.screenshot({ path: `${shotDir}/currency-probe.png` });
} else {
console.log(`admin page: ${adminUrl}`);
// Add-rate form: a currency code field + a rate field, then submit.
const codeInput = page.locator(
'input[name="code"], select[name="code"], input[name="currency_code"]',
).first();
const rateInput = page.locator(
'input[name="rate"], input[name="value"], input[name="currency_rate"]',
).first();
if (await codeInput.count() && await rateInput.count()) {
const tag = await codeInput.evaluate((e) => e.tagName.toLowerCase());
if (tag === "select") {
await codeInput.selectOption({ label: new RegExp(code, "i") as never })
.catch(async () => await codeInput.selectOption(code));
} else {
await codeInput.fill(code);
}
await rateInput.fill(rate);
await page.locator('input[type="submit"], button[type="submit"]').first()
.click();
await page.waitForLoadState("networkidle");
console.log(`submitted ${code} @ ${rate}`);
} else {
console.warn("add-rate form not recognised — reporting only");
}
await page.screenshot({ path: `${shotDir}/currency.png`, fullPage: true });
console.log(await page.locator("body").innerText().then((t) =>
t.split("\n").filter((l) => /USD|EUR|taux|rate/i.test(l)).slice(0, 8)
.join(" | ")
));
}
} finally {
await context.close();
await browser.close();
}