Merge pull request 'ci: add quality gates (semgrep, lint, e2e smoke, advisory PR-Agent)' (#1) from ci/quality-gates into main
CI / security (push) Successful in 37s
CI / lint (push) Successful in 2m48s
CI / e2e (push) Successful in 2m33s
CI / ai-review (push) Skipped

This commit was merged in pull request #1.
This commit is contained in:
2026-10-05 14:27:40 +00:00
52 changed files with 6100 additions and 1080 deletions
+107
View File
@@ -0,0 +1,107 @@
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
# Static analysis. Also the only job that runs on the weekly schedule, so newly published
# Semgrep registry rules are applied to main even when nothing is pushed.
security:
runs-on: bongbetic-ci
timeout-minutes: 15
container:
image: docker.io/semgrep/semgrep:1.178.0
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Semgrep
run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
lint:
if: gitea.event_name != 'schedule'
runs-on: bongbetic-ci
timeout-minutes: 20
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: ESLint
run: npx eslint .
- name: Rust format check
# rustfmt only. clippy is deliberately not run: it needs the webkit2gtk/gtk system libraries
# and a full compile of the Tauri crate, which is too heavy for the shared CI host.
working-directory: src-tauri
run: |
base=https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu
curl --proto '=https' --tlsv1.2 -sSfO "$base/rustup-init" -O "$base/rustup-init.sha256"
sha256sum -c rustup-init.sha256
chmod +x rustup-init
./rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.99.0 -c rustfmt
rm -f rustup-init rustup-init.sha256
"$HOME/.cargo/bin/cargo" fmt --check
- name: Duplicate code (jscpd)
run: npx --yes jscpd@4.3.0
e2e:
if: gitea.event_name != 'schedule'
runs-on: bongbetic-ci
timeout-minutes: 25
container:
# Keep this tag in step with the @playwright/test version in package.json.
image: mcr.microsoft.com/playwright:v1.63.0-noble
env:
CI: "true"
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Playwright smoke tests
run: npx playwright test
# Advisory only: never blocks a merge.
ai-review:
if: gitea.event_name == 'pull_request'
runs-on: bongbetic-ci
timeout-minutes: 10
continue-on-error: true
container:
image: docker.io/pragent/pr-agent:0.47.0
env:
config__git_provider: gitea
gitea__url: https://git.bongbetic.com
gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }}
openrouter__key: ${{ secrets.OPENROUTER_API_KEY }}
config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}
# PR-Agent needs this for OpenRouter models it has no built-in context window for.
config__custom_model_max_tokens: "200000"
steps:
- name: PR-Agent review
run: |
if [ -z "$gitea__personal_access_token" ] || [ -z "$openrouter__key" ]; then
echo "::notice::PR-Agent secrets not configured; skipping AI review."
exit 0
fi
pr-agent --pr_url="${{ gitea.event.pull_request.html_url }}" review
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
build: build:
runs-on: windows runs-on: windows
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
- name: Install Node - name: Install Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
with: with:
node-version: 20 node-version: 20
@@ -30,7 +30,7 @@ jobs:
run: npm run app:build run: npm run app:build
- name: Upload installer - name: Upload installer
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
with: with:
name: voiced-windows name: voiced-windows
path: src-tauri/target/release/bundle/nsis/*.exe path: src-tauri/target/release/bundle/nsis/*.exe
+4
View File
@@ -31,3 +31,7 @@ xwin/
CLAUDE.md CLAUDE.md
handoff.md handoff.md
.commandcode/ .commandcode/
# Playwright
test-results/
playwright-report/
+21
View File
@@ -0,0 +1,21 @@
{
"threshold": 4.5,
"reporters": ["console"],
"absolute": false,
"gitignore": true,
"path": ["."],
"ignore": [
"**/node_modules/**",
"**/package-lock.json",
"**/Cargo.lock",
"**/dist/**",
"**/target/**",
"public/**",
"docs/**",
"src-tauri/gen/**",
"src-tauri/icons/**",
"**/*.{woff,woff2,ttf,otf,png,jpg,jpeg,gif,ico,icns,svg,pdf}",
"**/playwright-report/**",
"**/test-results/**"
]
}
+50
View File
@@ -0,0 +1,50 @@
# CI
Workflow: `.gitea/workflows/ci.yml`, runner label `bongbetic-ci`. It runs on pull requests, pushes to `main` and manual dispatch. A weekly schedule (Monday 03:00 UTC) runs the `security` job only.
## Jobs
| Job | What it runs | Blocks merge? |
| --- | --- | --- |
| `security` | Semgrep (`p/default`, `p/owasp-top-ten`), fails on any finding | Yes |
| `lint` | `npm ci`, `tsc --noEmit`, ESLint, `cargo fmt --check` (src-tauri), jscpd (threshold 4.5%) | Yes |
| `e2e` | `npm run build`, then the Playwright smoke test (chromium) against `vite preview` | Yes |
| `ai-review` | PR-Agent review comment, pull requests only | No (advisory, `continue-on-error`) |
There is no deploy job: Voiced ships release assets, not a hosted app.
Semgrep registry rules are fetched at run time, so a new rule can fail a previously green `main`. The weekly schedule surfaces that early. Triage with a fix or a narrow `// nosemgrep: <rule-id> -- reason`.
Rust clippy is deferred. It needs the webkit2gtk/gtk system libraries and a full compile of the Tauri crate, which is too heavy for the shared CI host (jobs are limited to 3 GB RAM / 2 CPU). Only `cargo fmt --check` runs, with a pinned minimal rustup toolchain (rustfmt only).
## Run locally
```sh
# Semgrep (same image and rules as CI)
podman run --rm -v "$PWD:/src:ro,Z" -w /src docker.io/semgrep/semgrep:1.178.0 \
semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
npm run lint # ESLint
npx tsc --noEmit # typecheck
(cd src-tauri && cargo fmt --check)
npx jscpd@4.3.0 # duplicate code, reads .jscpd.json
npx playwright install chromium # once
npm run build && npm run e2e
```
Keep the `mcr.microsoft.com/playwright` image tag in the `e2e` job in step with the `@playwright/test` version in `package.json`.
## PR-Agent (advisory)
`ai-review` posts a review through PR-Agent using OpenRouter. It never pushes code and is skipped (with a notice) when the secrets are empty.
Repository secrets and variables:
- `OPENROUTER_API_KEY` (secret): OpenRouter API key.
- `PR_AGENT_GITEA_TOKEN` (secret): Gitea personal access token of the account that posts the review.
- `PR_AGENT_MODEL` (variable, optional): defaults to `openrouter/anthropic/claude-sonnet-5`.
## Rollback
Revert the workflow PR. If branch protection requires these checks (`security`, `lint`, `e2e`), relax it first, otherwise PRs will wait forever for checks that no longer run.
+69
View File
@@ -0,0 +1,69 @@
import { expect, test, type Page } from "@playwright/test";
// The app talks to its Rust backend through Tauri's `invoke`, which does not exist in a plain
// browser. These smoke tests cover the web build in both situations: without the backend (the app
// must fail gracefully) and with a minimal stubbed backend (the app shell must render).
const SETTINGS = {
vendorName: "Smoke Test Studio",
vendorAddress: "1 Test Street",
vendorEmail: "smoke@example.com",
vendorPhone: "",
vendorPan: "",
vendorGstin: "",
vendorStateCode: "",
logoPath: null,
signaturePath: null,
defaultBankId: null,
defaultTaxRate: 0,
defaultTaxType: "none",
paymentTermsDays: 15,
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
renderPrefs: "",
logoOriginalPath: null,
logoKnockoutPath: null,
logoMeta: "",
logoIncludesName: "auto",
};
function collectPageErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}
test("web build loads without a backend and fails gracefully", async ({ page }) => {
const errors = collectPageErrors(page);
await page.goto("/");
await expect(page).toHaveTitle("Voiced");
await expect(page.getByText("Could not start Voiced")).toBeVisible();
expect(errors).toEqual([]);
});
test("app shell renders with a stubbed backend", async ({ page }) => {
const errors = collectPageErrors(page);
await page.addInitScript((settings) => {
const w = window as unknown as { __TAURI_INTERNALS__: unknown };
w.__TAURI_INTERNALS__ = {
transformCallback: () => 0,
invoke: async (cmd: string) => {
if (cmd === "get_settings") return settings;
if (cmd.startsWith("list_")) return [];
return null;
},
};
}, SETTINGS);
await page.goto("/");
const nav = page.getByRole("navigation", { name: "Voiced navigation" });
await expect(nav).toBeVisible();
for (const label of ["Clients", "Settings"]) {
await expect(nav.getByText(label, { exact: true })).toBeVisible();
}
expect(errors).toEqual([]);
});
+50
View File
@@ -0,0 +1,50 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{
ignores: ["node_modules/", "dist/", "src-tauri/", "public/", "docs/", "playwright-report/", "test-results/"],
},
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
{
rules: {
// Leading underscore marks an intentionally unused binding (omitting a key via rest, unused
// override parameters).
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", ignoreRestSiblings: true },
],
// The BOM and NBSP are written literally on purpose in the CSV exporters and the text sanitiser.
"no-irregular-whitespace": ["error", { skipStrings: true, skipTemplates: true, skipRegExps: true }],
},
},
{
// The React Compiler rules shipped in eslint-plugin-react-hooks 7 flag long-standing patterns
// here (load data on mount, "latest value" refs written during render). They are legitimate
// design feedback but not bugs, and rewriting them blind risks behaviour changes in an
// effect-heavy app, so they stay visible as warnings until each site is reworked.
files: ["src/**/*.{ts,tsx}"],
rules: {
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/immutability": "warn",
},
},
{
// Test helper that reads react-pdf's untyped internal layout data.
files: ["src/pdf/testing/**/*.ts"],
rules: { "@typescript-eslint/no-explicit-any": "warn" },
},
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: { globals: globals.browser },
},
{
files: ["scripts/**/*.{js,mjs}", "e2e/**/*.ts", "*.config.{js,ts}"],
languageOptions: { globals: globals.node },
},
);
+1322 -2
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -23,7 +23,9 @@
"decor:script-word": "node scripts/decor/script-word.mjs", "decor:script-word": "node scripts/decor/script-word.mjs",
"decor:icons": "node scripts/decor/icons.mjs", "decor:icons": "node scripts/decor/icons.mjs",
"thumbnails": "node scripts/thumbnails.mjs", "thumbnails": "node scripts/thumbnails.mjs",
"templates:compare": "node scripts/templates/compare.mjs" "templates:compare": "node scripts/templates/compare.mjs",
"lint": "eslint .",
"e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.89.0", "@carbon/icons-react": "^11.89.0",
@@ -40,15 +42,21 @@
"react-is": "^19.3.0" "react-is": "^19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.63.0",
"@tauri-apps/cli": "^2.12.1", "@tauri-apps/cli": "^2.12.1",
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"clipper-lib": "^6.4.2", "clipper-lib": "^6.4.2",
"eslint": "10.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.13.0",
"pdfjs-dist": "6.4.299", "pdfjs-dist": "6.4.299",
"sass": "^1.105.1", "sass": "^1.105.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "8.71.0",
"vite": "^7.3.6", "vite": "^7.3.6",
"vitest": "^5.0.3" "vitest": "^5.0.3"
}, },
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4173;
export default defineConfig({
testDir: "./e2e",
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: `http://localhost:${PORT}`,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Serves the production build (run `npm run build` first).
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
-1
View File
@@ -52,7 +52,6 @@ function wordCommands(font) {
function rotate(cmds) { function rotate(cmds) {
const pts = cmds.flatMap((c) => c.args); const pts = cmds.flatMap((c) => c.args);
const xs = pts.filter((_, i) => i % 2 === 0); const xs = pts.filter((_, i) => i % 2 === 0);
const ys = pts.filter((_, i) => i % 2 === 1);
const inkLen = Math.max(...xs) - Math.min(...xs); const inkLen = Math.max(...xs) - Math.min(...xs);
const s = INK_LENGTH / inkLen; const s = INK_LENGTH / inkLen;
const map = (x, y) => [-y * s, -x * s]; const map = (x, y) => [-y * s, -x * s];
+1 -1
View File
@@ -107,7 +107,7 @@ async function main() {
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` }, invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
}); });
writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png")); writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png"));
console.log(`${id}: pages ${withLogo.result.pages}/${sixty.result.pages}/${letter.result.pages}/${long.result.pages}; audit issues`, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/")); console.log("%s: pages %s/%s/%s/%s; audit issues %s", id, withLogo.result.pages, sixty.result.pages, letter.result.pages, long.result.pages, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/"));
for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160)); for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160));
} }
} }
+67 -19
View File
@@ -30,7 +30,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
/// The hash comes from the database, but it ends up in a file path, so check its shape. /// The hash comes from the database, but it ends up in a file path, so check its shape.
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> { fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); let valid = sha256.len() == 64
&& sha256
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if !valid { if !valid {
return Err("The stored archive hash is malformed".to_string()); return Err("The stored archive hash is malformed".to_string());
} }
@@ -72,7 +75,9 @@ pub fn archive_pdf_impl(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?; let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" && status != "cancelled" { if status != "issued" && status != "cancelled" {
return Err(format!("Only an issued invoice can be archived (this one is {status})")); return Err(format!(
"Only an issued invoice can be archived (this one is {status})"
));
} }
let sha = sha256_hex(bytes); let sha = sha256_hex(bytes);
@@ -89,7 +94,9 @@ pub fn archive_pdf_impl(
std::fs::create_dir_all(path.parent().expect("archive path has a parent")) std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
.map_err(|e| format!("Could not create the archive folder: {e}"))?; .map_err(|e| format!("Could not create the archive folder: {e}"))?;
// Same content, same name; a matching length means the file is already in place. // Same content, same name; a matching length means the file is already in place.
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false); let present = std::fs::metadata(&path)
.map(|m| m.len() == bytes.len() as u64)
.unwrap_or(false);
if !present { if !present {
write_atomic(&path, bytes)?; write_atomic(&path, bytes)?;
} }
@@ -107,7 +114,8 @@ pub fn archive_pdf_impl(
} }
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> { pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?; let sha =
stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
let path = archive_path(local_dir, &sha)?; let path = archive_path(local_dir, &sha)?;
let bytes = std::fs::read(&path).map_err(|e| { let bytes = std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
@@ -124,7 +132,11 @@ pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> { fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
let row: Option<Option<String>> = conn let row: Option<Option<String>> = conn
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) .query_row(
"SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1",
params![id],
|r| r.get(0),
)
.optional() .optional()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
row.ok_or_else(|| "Invoice not found".to_string()) row.ok_or_else(|| "Invoice not found".to_string())
@@ -140,12 +152,20 @@ pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus,
.optional() .optional()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?; let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at }) Ok(ArchiveStatus {
archived: sha256.is_some(),
sha256,
fingerprint,
archived_at,
})
} }
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread. // The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
#[tauri::command] #[tauri::command]
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> { pub async fn archive_pdf(
request: Request<'_>,
state: State<'_, AppState>,
) -> Result<String, String> {
let bytes = raw_body(&request)?; let bytes = raw_body(&request)?;
let headers = header_map(&request); let headers = header_map(&request);
let mut conn = state.db.lock().map_err(|e| e.to_string())?; let mut conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -159,7 +179,10 @@ pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result
} }
#[tauri::command] #[tauri::command]
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> { pub async fn archive_status(
invoice_id: i64,
state: State<'_, AppState>,
) -> Result<ArchiveStatus, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
archive_status_impl(&conn, invoice_id) archive_status_impl(&conn, invoice_id)
} }
@@ -195,7 +218,10 @@ mod tests {
let mut conn = conn_with_invoice("issued"); let mut conn = conn_with_invoice("issued");
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(sha.len(), 64); assert_eq!(sha.len(), 64);
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF); assert_eq!(
std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(),
PDF
);
let status = archive_status_impl(&conn, 1).unwrap(); let status = archive_status_impl(&conn, 1).unwrap();
assert!(status.archived); assert!(status.archived);
@@ -205,7 +231,10 @@ mod tests {
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(again, sha); assert_eq!(again, sha);
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at); assert_eq!(
archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(),
first_at
);
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
} }
@@ -221,19 +250,27 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued"); let mut conn = conn_with_invoice("issued");
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err(); let err =
archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
assert!(err.contains("already has an archived original"), "{err}"); assert!(err.contains("already has an archived original"), "{err}");
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha); assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1); assert_eq!(
std::fs::read_dir(dir.path().join("archive"))
.unwrap()
.count(),
1
);
} }
#[test] #[test]
fn non_pdf_and_oversize_bodies_are_refused() { fn non_pdf_and_oversize_bodies_are_refused() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued"); let mut conn = conn_with_invoice("issued");
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1")) assert!(
archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
.unwrap_err() .unwrap_err()
.contains("not a PDF")); .contains("not a PDF")
);
let mut big = b"%PDF-".to_vec(); let mut big = b"%PDF-".to_vec();
big.resize(MAX_ARCHIVE_BYTES + 1, 0); big.resize(MAX_ARCHIVE_BYTES + 1, 0);
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1")) assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
@@ -264,15 +301,21 @@ mod tests {
fn read_archive_detects_corruption_and_missing_files() { fn read_archive_detects_corruption_and_missing_files() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued"); let mut conn = conn_with_invoice("issued");
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF")); assert!(read_archive_impl(&conn, dir.path(), 1)
.unwrap_err()
.contains("no archived PDF"));
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
let file = dir.path().join("archive").join(format!("{sha}.pdf")); let file = dir.path().join("archive").join(format!("{sha}.pdf"));
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap(); std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted")); assert!(read_archive_impl(&conn, dir.path(), 1)
.unwrap_err()
.contains("corrupted"));
std::fs::remove_file(&file).unwrap(); std::fs::remove_file(&file).unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing")); assert!(read_archive_impl(&conn, dir.path(), 1)
.unwrap_err()
.contains("missing"));
// Archiving the same bytes again restores the file. // Archiving the same bytes again restores the file.
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
@@ -282,8 +325,13 @@ mod tests {
fn a_malformed_stored_hash_never_becomes_a_path() { fn a_malformed_stored_hash_never_becomes_a_path() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let conn = conn_with_invoice("issued"); let conn = conn_with_invoice("issued");
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", []) conn.execute(
"UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1",
[],
)
.unwrap(); .unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed")); assert!(read_archive_impl(&conn, dir.path(), 1)
.unwrap_err()
.contains("malformed"));
} }
} }
+28 -6
View File
@@ -42,7 +42,11 @@ pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
if raw.components().any(|c| matches!(c, Component::ParentDir)) { if raw.components().any(|c| matches!(c, Component::ParentDir)) {
return Err("Asset path must not contain '..'".into()); return Err("Asset path must not contain '..'".into());
} }
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; let full = if raw.is_absolute() {
raw.to_path_buf()
} else {
data_dir.join(raw)
};
let root = data_dir let root = data_dir
.join("assets") .join("assets")
.canonicalize() .canonicalize()
@@ -73,7 +77,12 @@ pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> { fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
let (ext, _) = sniff_image(bytes)?; let (ext, _) = sniff_image(bytes)?;
let dir = assets_dir(data_dir)?; let dir = assets_dir(data_dir)?;
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext); let file = format!(
"{}-{}.{}",
safe_kind(kind),
uuid::Uuid::new_v4().simple(),
ext
);
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?; std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
Ok(format!("assets/{file}")) Ok(format!("assets/{file}"))
} }
@@ -105,7 +114,11 @@ fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> { fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
let raw = Path::new(path); let raw = Path::new(path);
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; let full = if raw.is_absolute() {
raw.to_path_buf()
} else {
data_dir.join(raw)
};
// Removing something that is already gone is fine, but an escape attempt is not. // Removing something that is already gone is fine, but an escape attempt is not.
if std::fs::symlink_metadata(&full).is_err() { if std::fs::symlink_metadata(&full).is_err() {
return Ok(()); return Ok(());
@@ -163,7 +176,10 @@ mod tests {
fn sniffing_accepts_only_png_and_jpeg() { fn sniffing_accepts_only_png_and_jpeg() {
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png")); assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg")); assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image"); assert_eq!(
sniff_image(b"GIF89a....").unwrap_err(),
"Use a PNG or JPEG image"
);
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err()); assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err()); assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
assert!(sniff_image(b"").is_err()); assert!(sniff_image(b"").is_err());
@@ -173,7 +189,10 @@ mod tests {
fn write_ignores_the_name_and_stores_a_relative_path() { fn write_ignores_the_name_and_stores_a_relative_path() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap(); let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}"); assert!(
stored.starts_with("assets/logo-") && stored.ends_with(".jpg"),
"{stored}"
);
assert!(dir.path().join(&stored).is_file()); assert!(dir.path().join(&stored).is_file());
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err()); assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
let uri = asset_data_uri(dir.path(), &stored).unwrap(); let uri = asset_data_uri(dir.path(), &stored).unwrap();
@@ -210,7 +229,10 @@ mod tests {
let absolute = dir.path().join(&stored); let absolute = dir.path().join(&stored);
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap(); let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
assert_eq!(by_relative, by_absolute); assert_eq!(by_relative, by_absolute);
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored); assert_eq!(
relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(),
stored
);
} }
#[test] #[test]
File diff suppressed because it is too large Load Diff
+32 -9
View File
@@ -4,9 +4,16 @@ use crate::AppState;
use rusqlite::{params, Connection, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; use tauri::State;
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"]; const CATEGORIES: &[&str] = &[
"registered_regular",
"unregistered",
"composition",
"sez",
"overseas",
];
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at, const CLIENT_COLS: &str =
"c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes, c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
c.payment_terms_days, c.payment_terms_days,
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)"; (SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
@@ -33,7 +40,13 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then /// Composes the stored multi-line address from the structured parts: line 1, line 2, then
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts. /// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String { pub fn compose_address(
line1: &str,
line2: &str,
city: &str,
state_code: &str,
pincode: &str,
) -> String {
let state_name = gst::STATES let state_name = gst::STATES
.iter() .iter()
.find(|(c, _)| *c == state_code) .find(|(c, _)| *c == state_code)
@@ -246,7 +259,10 @@ mod tests {
c.po_number = "PO-9".into(); c.po_number = "PO-9".into();
let saved = save_client_impl(&conn, c).unwrap(); let saved = save_client_impl(&conn, c).unwrap();
assert!(saved.id.is_some()); assert!(saved.id.is_some());
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"); assert_eq!(
saved.address,
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
);
assert_eq!(saved.gstin, "29ABCDE1234F1ZW"); assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
let listed = list_clients_impl(&conn).unwrap(); let listed = list_clients_impl(&conn).unwrap();
assert_eq!(listed.len(), 1); assert_eq!(listed.len(), 1);
@@ -282,7 +298,10 @@ mod tests {
edit.name = "B".into(); edit.name = "B".into();
edit.gst_category = "sez".into(); edit.gst_category = "sez".into();
let again = save_client_impl(&conn, edit).unwrap(); let again = save_client_impl(&conn, edit).unwrap();
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez")); assert_eq!(
(again.name.as_str(), again.gst_category.as_str()),
("B", "sez")
);
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1); assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
let mut ghost = blank("Ghost"); let mut ghost = blank("Ghost");
ghost.id = Some(999); ghost.id = Some(999);
@@ -292,7 +311,9 @@ mod tests {
#[test] #[test]
fn validation_rejects_bad_input() { fn validation_rejects_bad_input() {
let conn = open_in_memory().unwrap(); let conn = open_in_memory().unwrap();
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name")); assert!(save_client_impl(&conn, blank(" "))
.unwrap_err()
.contains("name"));
let mut c = blank("X"); let mut c = blank("X");
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
@@ -342,9 +363,11 @@ mod tests {
delete_client_impl(&conn, id).unwrap(); delete_client_impl(&conn, id).unwrap();
assert!(list_clients_impl(&conn).unwrap().is_empty()); assert!(list_clients_impl(&conn).unwrap().is_empty());
let (name, addr): (String, String) = conn let (name, addr): (String, String) = conn
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?)) "SELECT client_name, client_address FROM invoices WHERE number='T-1'",
}) [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap(); .unwrap();
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr")); assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
} }
+50 -14
View File
@@ -5,8 +5,12 @@
//! never held across a network call. //! never held across a network call.
use crate::integrations::erpnext::client::ErpClient; use crate::integrations::erpnext::client::ErpClient;
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView}; use crate::integrations::erpnext::config::{
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts}; self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView,
};
use crate::integrations::erpnext::discovery::{
self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts,
};
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult}; use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
use crate::integrations::SyncStatus; use crate::integrations::SyncStatus;
use crate::AppState; use crate::AppState;
@@ -18,12 +22,18 @@ pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
} }
/// Merges the form with the stored row (blank secret keeps the stored one) without saving. /// Merges the form with the stored row (blank secret keeps the stored one) without saving.
pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfig, String> { pub fn resolve_config(
conn: &Connection,
input: ErpnextConfigInput,
) -> Result<ErpnextConfig, String> {
let stored = config::load(conn)?; let stored = config::load(conn)?;
input.resolve(&stored) input.resolve(&stored)
} }
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> { pub fn save_config_impl(
conn: &Connection,
input: ErpnextConfigInput,
) -> Result<ErpnextConfigView, String> {
let cfg = resolve_config(conn, input)?; let cfg = resolve_config(conn, input)?;
config::save(conn, &cfg)?; config::save(conn, &cfg)?;
Ok(cfg.view()) Ok(cfg.view())
@@ -32,7 +42,11 @@ pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<
/// What the connection test needs from the local database. /// What the connection test needs from the local database.
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> { pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
let registration: String = conn let registration: String = conn
.query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0)) .query_row(
"SELECT gst_registration FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut stmt = conn let mut stmt = conn
.prepare("SELECT number FROM invoices ORDER BY id") .prepare("SELECT number FROM invoices ORDER BY id")
@@ -54,7 +68,11 @@ pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
) )
.ok() .ok()
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next)); .map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number }) Ok(LocalFacts {
vendor_registered: registration != "unregistered",
invalid_numbers,
next_number,
})
} }
#[tauri::command] #[tauri::command]
@@ -65,7 +83,10 @@ pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, S
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it. /// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
#[tauri::command] #[tauri::command]
pub fn erpnext_save_config(state: State<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> { pub fn erpnext_save_config(
state: State<AppState>,
config: ErpnextConfigInput,
) -> Result<ErpnextConfigView, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
save_config_impl(&conn, config) save_config_impl(&conn, config)
} }
@@ -105,7 +126,10 @@ fn saved_client(state: &AppState) -> Result<ErpClient, String> {
config::load(&conn)? config::load(&conn)?
}; };
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() { if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into()); return Err(
"ERPNext is not set up yet: enter the address, API key and API secret in Settings."
.into(),
);
} }
Ok(ErpClient::from_config(&cfg)?) Ok(ErpClient::from_config(&cfg)?)
} }
@@ -134,7 +158,10 @@ pub async fn erpnext_push_invoices(
} }
#[tauri::command] #[tauri::command]
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> { pub async fn erpnext_push_payment(
state: State<'_, AppState>,
payment_id: i64,
) -> Result<PaymentPushResult, String> {
let http = saved_client(&state)?; let http = saved_client(&state)?;
Ok(push::push_payment(&state.db, &http, payment_id).await) Ok(push::push_payment(&state.db, &http, payment_id).await)
} }
@@ -165,15 +192,22 @@ mod tests {
use serde_json::json; use serde_json::json;
fn input(extra: serde_json::Value) -> ErpnextConfigInput { fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" }); let mut base =
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
base.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap() serde_json::from_value(base).unwrap()
} }
#[test] #[test]
fn saved_config_is_returned_without_the_secret() { fn saved_config_is_returned_without_the_secret() {
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap(); let view = save_config_impl(
&conn,
input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })),
)
.unwrap();
assert!(view.api_secret_set); assert!(view.api_secret_set);
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap(); let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
assert!(!shown.contains("super-secret-value")); assert!(!shown.contains("super-secret-value"));
@@ -190,7 +224,8 @@ mod tests {
#[test] #[test]
fn test_values_are_resolved_without_saving() { fn test_values_are_resolved_without_saving() {
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap(); let resolved =
resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
assert_eq!(resolved.api_secret.expose(), "typed-not-saved"); assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
assert!(!get_config_impl(&conn).unwrap().api_secret_set); assert!(!get_config_impl(&conn).unwrap().api_secret_set);
} }
@@ -203,7 +238,8 @@ mod tests {
assert!(facts.invalid_numbers.is_empty()); assert!(facts.invalid_numbers.is_empty());
assert!(facts.next_number.is_some()); assert!(facts.next_number.is_some());
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap(); conn.execute("UPDATE app_settings SET gst_registration = 'regular'", [])
.unwrap();
conn.execute( conn.execute(
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) "INSERT INTO invoices (number, invoice_date, created_at, updated_at)
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')", VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
+45 -12
View File
@@ -1,6 +1,8 @@
//! User-visible files: exports written to a path from the save dialog, and the //! User-visible files: exports written to a path from the save dialog, and the
//! reveal/open helpers used afterwards. //! reveal/open helpers used afterwards.
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; use super::raw::{
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
};
use crate::AppState; use crate::AppState;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -25,7 +27,9 @@ pub fn write_export_file_impl(
if bytes.is_empty() { if bytes.is_empty() {
return Err("Nothing to write: the file is empty".to_string()); return Err("Nothing to write: the file is empty".to_string());
} }
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?; let parent = path
.parent()
.ok_or_else(|| "The export path has no folder".to_string())?;
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?; .map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
write_atomic(&path, bytes)?; write_atomic(&path, bytes)?;
@@ -38,7 +42,11 @@ pub fn write_export_file_impl(
} }
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> { pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) conn.query_row(
"SELECT last_export_dir FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
} }
#[cfg(not(any(target_os = "windows", target_os = "macos")))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
{ {
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) }; let folder = if path.is_dir() {
path
} else {
path.parent().unwrap_or(path)
};
let mut cmd = Command::new("xdg-open"); let mut cmd = Command::new("xdg-open");
cmd.arg(folder); cmd.arg(folder);
cmd cmd
@@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command {
} }
fn spawn_detached(mut cmd: Command) -> Result<(), String> { fn spawn_detached(mut cmd: Command) -> Result<(), String> {
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?; let mut child = cmd
.spawn()
.map_err(|e| format!("Could not start the file manager: {e}"))?;
// Reap the child so it does not linger as a zombie; the result is irrelevant // Reap the child so it does not linger as a zombie; the result is irrelevant
// (explorer.exe, for one, exits non-zero even on success). // (explorer.exe, for one, exits non-zero even on success).
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> { pub async fn write_export_file(
request: Request<'_>,
state: State<'_, AppState>,
) -> Result<String, String> {
let bytes = raw_body(&request)?; let bytes = raw_body(&request)?;
let headers = header_map(&request); let headers = header_map(&request);
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -125,7 +142,11 @@ pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin
#[tauri::command] #[tauri::command]
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
let path = require_existing(&path)?; let path = require_existing(&path)?;
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() { if app
.opener()
.open_path(path.to_string_lossy(), None::<&str>)
.is_ok()
{
return Ok(()); return Ok(());
} }
spawn_detached(open_fallback_command(&path)) spawn_detached(open_fallback_command(&path))
@@ -141,7 +162,10 @@ mod tests {
} }
fn encode(path: &Path) -> String { fn encode(path: &Path) -> String {
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC) percent_encoding::utf8_percent_encode(
&path.to_string_lossy(),
percent_encoding::NON_ALPHANUMERIC,
)
.to_string() .to_string()
} }
@@ -152,8 +176,12 @@ mod tests {
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
// Non-ASCII, spaces and a folder that does not exist yet. // Non-ASCII, spaces and a folder that does not exist yet.
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf"); let target = dir
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap(); .path()
.join("New folder")
.join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
let written =
write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
assert_eq!(PathBuf::from(&written), target); assert_eq!(PathBuf::from(&written), target);
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7"); assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
assert_eq!( assert_eq!(
@@ -170,7 +198,10 @@ mod tests {
fn rejects_empty_relative_and_missing_paths() { fn rejects_empty_relative_and_missing_paths() {
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] { for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}"); assert!(
write_export_file_impl(&conn, b"x", &headers(bad)).is_err(),
"{bad}"
);
} }
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err()); assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
@@ -191,7 +222,9 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
assert!(require_existing("").is_err()); assert!(require_existing("").is_err());
let missing = dir.path().join("nope.pdf"); let missing = dir.path().join("nope.pdf");
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist")); assert!(require_existing(&missing.to_string_lossy())
.unwrap_err()
.contains("does not exist"));
assert!(require_existing(&dir.path().to_string_lossy()).is_ok()); assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
} }
+363 -85
View File
@@ -9,7 +9,9 @@
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType` //! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed //! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
//! to describe the font are read. //! to describe the font are read.
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; use super::raw::{
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
};
use crate::AppState; use crate::AppState;
use flate2::read::ZlibDecoder; use flate2::read::ZlibDecoder;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
@@ -119,7 +121,10 @@ impl<'a> Sfnt<'a> {
let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize; let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize;
let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize; let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize;
if off.checked_add(len).map_or(true, |end| end > data.len()) { if off.checked_add(len).map_or(true, |end| end > data.len()) {
return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag))); return Err(format!(
"Table '{}' lies outside the file",
String::from_utf8_lossy(&tag)
));
} }
tables.push((tag, off, len)); tables.push((tag, off, len));
} }
@@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> {
} }
fn utf16be(b: &[u8]) -> String { fn utf16be(b: &[u8]) -> String {
let units: Vec<u16> = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); let units: Vec<u16> = b
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units) String::from_utf16_lossy(&units)
} }
@@ -201,7 +209,11 @@ impl<'a> Cmap<'a> {
best = Some((rank, offset, format)); best = Some((rank, offset, format));
} }
} }
best.map(|(_, offset, format)| Self { table, offset, format }) best.map(|(_, offset, format)| Self {
table,
offset,
format,
})
} }
fn has(&self, cp: u32) -> bool { fn has(&self, cp: u32) -> bool {
@@ -283,7 +295,9 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
let comp = be32(data, rec + 8).ok_or_else(bad)? as usize; let comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
let orig = be32(data, rec + 12).ok_or_else(bad)? as usize; let orig = be32(data, rec + 12).ok_or_else(bad)? as usize;
let checksum = be32(data, rec + 16).ok_or_else(bad)?; let checksum = be32(data, rec + 16).ok_or_else(bad)?;
let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?; let stored = data
.get(off..off.checked_add(comp).ok_or_else(bad)?)
.ok_or_else(bad)?;
total += (orig + 3) & !3; total += (orig + 3) & !3;
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES { if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
return Err("The font expands to more than the 16 MB limit".into()); return Err("The font expands to more than the 16 MB limit".into());
@@ -293,7 +307,10 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
} else if comp < orig { } else if comp < orig {
let mut out = Vec::with_capacity(orig); let mut out = Vec::with_capacity(orig);
// One byte over the stated size detects a table that lies about it, without trusting it for allocation. // One byte over the stated size detects a table that lies about it, without trusting it for allocation.
ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?; ZlibDecoder::new(stored)
.take(orig as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| bad())?;
if out.len() != orig { if out.len() != orig {
return Err(bad()); return Err(bad());
} }
@@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
} else { } else {
return Err(bad()); return Err(bad());
}; };
entries.push(Entry { tag, checksum, bytes }); entries.push(Entry {
tag,
checksum,
bytes,
});
} }
entries.sort_by_key(|e| e.tag); entries.sort_by_key(|e| e.tag);
@@ -372,12 +393,18 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
} }
let cff = font.table(b"CFF ").is_some(); let cff = font.table(b"CFF ").is_some();
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) { if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into()); return Err(
"The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded."
.into(),
);
} }
let head = font.table(b"head").ok_or("The font has no head table")?; let head = font.table(b"head").ok_or("The font has no head table")?;
let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?; let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?;
let name = font.table(b"name").ok_or("The font has no name table")?; let name = font.table(b"name").ok_or("The font has no name table")?;
let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?; let num_glyphs = font
.table(b"maxp")
.and_then(|m| be16(m, 4))
.ok_or("The font has no usable maxp table")?;
let os2 = font.table(b"OS/2"); let os2 = font.table(b"OS/2");
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0); let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
@@ -413,7 +440,11 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0); let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
let mac_style = be16(head, 44).unwrap_or(0); let mac_style = be16(head, 44).unwrap_or(0);
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic. // fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 }; let italic = if os2.map_or(false, |t| t.len() >= 64) {
fs_selection & 0x0201 != 0
} else {
mac_style & 0x2 != 0
};
let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?; let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
let has_rupee = cmap.has(0x20B9); let has_rupee = cmap.has(0x20B9);
@@ -428,9 +459,14 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string()); warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string());
} }
let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default(); let family = name_string(name, 16)
let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default(); .or_else(|| name_string(name, 1))
let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string()); .unwrap_or_default();
let subfamily = name_string(name, 17)
.or_else(|| name_string(name, 2))
.unwrap_or_default();
let full_name =
name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
let postscript_name = name_string(name, 6).unwrap_or_default(); let postscript_name = name_string(name, 6).unwrap_or_default();
if family.is_empty() && full_name.is_empty() { if family.is_empty() && full_name.is_empty() {
return Err("The font has no readable name".into()); return Err("The font has no readable name".into());
@@ -460,7 +496,10 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
// storage // storage
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> { fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); let valid = sha256.len() == 64
&& sha256
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if !valid { if !valid {
return Err("The stored font hash is malformed".to_string()); return Err("The stored font hash is malformed".to_string());
} }
@@ -493,19 +532,31 @@ fn map_row(r: &rusqlite::Row) -> rusqlite::Result<UserFont> {
} }
fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> { fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> {
conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row) conn.query_row(
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
params![id],
map_row,
)
.optional() .optional()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result<Vec<UserFont>, String> { pub fn list_user_fonts_impl(
conn: &Connection,
include_hidden: bool,
) -> Result<Vec<UserFont>, String> {
let sql = format!( let sql = format!(
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id", "SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
if include_hidden { "" } else { "WHERE hidden = 0" } if include_hidden {
""
} else {
"WHERE hidden = 0"
}
); );
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?; let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string()) rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
} }
/// Whether any invoice's frozen render prefs name this font file. /// Whether any invoice's frozen render prefs name this font file.
@@ -531,7 +582,11 @@ pub struct ImportRequest<'a> {
fn clean_file_name(raw: &str) -> String { fn clean_file_name(raw: &str) -> String {
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw); let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect(); let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
if cleaned.trim().is_empty() { "font".to_string() } else { cleaned } if cleaned.trim().is_empty() {
"font".to_string()
} else {
cleaned
}
} }
pub fn import_font_impl( pub fn import_font_impl(
@@ -541,7 +596,10 @@ pub fn import_font_impl(
req: &ImportRequest, req: &ImportRequest,
) -> Result<UserFont, String> { ) -> Result<UserFont, String> {
if !FACES.contains(&req.face) { if !FACES.contains(&req.face) {
return Err(format!("Fonts can be imported for {} only", FACES.join(", "))); return Err(format!(
"Fonts can be imported for {} only",
FACES.join(", ")
));
} }
if !req.licence_acknowledged { if !req.licence_acknowledged {
return Err("Confirm that you hold a licence for this font before importing it".into()); return Err("Confirm that you hold a licence for this font before importing it".into());
@@ -567,7 +625,9 @@ pub fn import_font_impl(
let path = font_path(local_dir, &info.sha256, &info.format)?; let path = font_path(local_dir, &info.sha256, &info.format)?;
std::fs::create_dir_all(path.parent().expect("font path has a parent")) std::fs::create_dir_all(path.parent().expect("font path has a parent"))
.map_err(|e| format!("Could not create the fonts folder: {e}"))?; .map_err(|e| format!("Could not create the fonts folder: {e}"))?;
let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false); let present = std::fs::metadata(&path)
.map(|m| m.len() == sfnt.len() as u64)
.unwrap_or(false);
if !present { if !present {
write_atomic(&path, &sfnt)?; write_atomic(&path, &sfnt)?;
} }
@@ -628,12 +688,21 @@ pub fn import_font_impl(
fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> { fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> {
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?; let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
if referenced_by_invoice(conn, &font.sha256)? { if referenced_by_invoice(conn, &font.sha256)? {
conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute(
"UPDATE user_fonts SET hidden = 1 WHERE id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
return Ok(None); return Ok(None);
} }
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
let shared: i64 = conn let shared: i64 = conn
.query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0)) .query_row(
"SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1",
params![font.sha256],
|r| r.get(0),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if shared > 0 { if shared > 0 {
return Ok(None); return Ok(None);
@@ -641,11 +710,19 @@ fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf
font_path(local_dir, &font.sha256, &font.format).map(Some) font_path(local_dir, &font.sha256, &font.format).map(Some)
} }
pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -> Result<RemoveOutcome, String> { pub fn remove_user_font_impl(
conn: &mut Connection,
local_dir: &Path,
id: i64,
) -> Result<RemoveOutcome, String> {
let tx = conn.transaction().map_err(|e| e.to_string())?; let tx = conn.transaction().map_err(|e| e.to_string())?;
let file = retire(&tx, local_dir, id)?; let file = retire(&tx, local_dir, id)?;
let still_there: bool = tx let still_there: bool = tx
.query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0)) .query_row(
"SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)",
params![id],
|r| r.get::<_, i64>(0),
)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
!= 0; != 0;
tx.commit().map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?;
@@ -653,13 +730,24 @@ pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -
// Best effort, as in import: the row is already gone. // Best effort, as in import: the row is already gone.
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
Ok(RemoveOutcome { deleted: !still_there, hidden: still_there }) Ok(RemoveOutcome {
deleted: !still_there,
hidden: still_there,
})
} }
/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it). /// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it).
pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result<Vec<u8>, String> { pub fn user_font_bytes_impl(
conn: &Connection,
local_dir: &Path,
sha256: &str,
) -> Result<Vec<u8>, String> {
let format: Option<String> = conn let format: Option<String> = conn
.query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0)) .query_row(
"SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1",
params![sha256],
|r| r.get(0),
)
.optional() .optional()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let format = format.ok_or_else(|| "This font is not in the font library".to_string())?; let format = format.ok_or_else(|| "This font is not in the font library".to_string())?;
@@ -677,20 +765,43 @@ pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -
Ok(bytes) Ok(bytes)
} }
fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result<ImportRequest<'a>, String> { fn parse_import_headers<'a>(
headers: &'a Headers,
file_name: &'a str,
) -> Result<ImportRequest<'a>, String> {
let face = required_header(headers, "x-face")?; let face = required_header(headers, "x-face")?;
let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) { let weight = match headers
.get("x-weight")
.map(|v| v.trim())
.filter(|v| !v.is_empty())
{
None => None, None => None,
Some(v) => Some(v.parse::<u16>().map_err(|_| "x-weight must be a number".to_string())?), Some(v) => Some(
v.parse::<u16>()
.map_err(|_| "x-weight must be a number".to_string())?,
),
}; };
let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) { let italic = match headers
.get("x-style")
.map(|v| v.trim())
.filter(|v| !v.is_empty())
{
None => None, None => None,
Some("normal") => Some(false), Some("normal") => Some(false),
Some("italic") => Some(true), Some("italic") => Some(true),
Some(_) => return Err("x-style must be normal or italic".into()), Some(_) => return Err("x-style must be normal or italic".into()),
}; };
let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false); let licence_acknowledged = headers
Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged }) .get("x-licence-ack")
.map(|v| v.trim() == "true")
.unwrap_or(false);
Ok(ImportRequest {
face,
file_name,
weight,
italic,
licence_acknowledged,
})
} }
// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread. // The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread.
@@ -701,10 +812,18 @@ pub async fn inspect_font(request: Request<'_>) -> Result<FontInspection, String
} }
#[tauri::command] #[tauri::command]
pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Result<UserFont, String> { pub async fn import_font(
request: Request<'_>,
state: State<'_, AppState>,
) -> Result<UserFont, String> {
let bytes = raw_body(&request)?; let bytes = raw_body(&request)?;
let headers = header_map(&request); let headers = header_map(&request);
let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?; let file_name = decode_header_path(
headers
.get("x-file-name")
.map(String::as_str)
.unwrap_or("font"),
)?;
let face = decode_header_path(required_header(&headers, "x-face")?)?; let face = decode_header_path(required_header(&headers, "x-face")?)?;
let mut headers = headers; let mut headers = headers;
headers.insert("x-face".to_string(), face); headers.insert("x-face".to_string(), face);
@@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re
} }
#[tauri::command] #[tauri::command]
pub async fn list_user_fonts(include_hidden: Option<bool>, state: State<'_, AppState>) -> Result<Vec<UserFont>, String> { pub async fn list_user_fonts(
include_hidden: Option<bool>,
state: State<'_, AppState>,
) -> Result<Vec<UserFont>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false)) list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
} }
#[tauri::command] #[tauri::command]
pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result<Response, String> { pub async fn get_user_font_bytes(
sha256: String,
state: State<'_, AppState>,
) -> Result<Response, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new) user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
} }
#[tauri::command] #[tauri::command]
pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result<RemoveOutcome, String> { pub async fn remove_user_font(
id: i64,
state: State<'_, AppState>,
) -> Result<RemoveOutcome, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?; let mut conn = state.db.lock().map_err(|e| e.to_string())?;
remove_user_font_impl(&mut conn, &state.local_data_dir, id) remove_user_font_impl(&mut conn, &state.local_data_dir, id)
} }
@@ -740,7 +868,9 @@ mod tests {
use tempfile::tempdir; use tempfile::tempdir;
fn jost(name: &str) -> Vec<u8> { fn jost(name: &str) -> Vec<u8> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name); let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../public/fonts/jost")
.join(name);
std::fs::read(path).unwrap() std::fs::read(path).unwrap()
} }
fn regular() -> Vec<u8> { fn regular() -> Vec<u8> {
@@ -781,7 +911,11 @@ mod tests {
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(raw).unwrap(); enc.write_all(raw).unwrap();
let z = enc.finish().unwrap(); let z = enc.finish().unwrap();
blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) }); blobs.push(if z.len() < raw.len() {
(*tag, z, raw.len())
} else {
(*tag, raw.to_vec(), raw.len())
});
} }
let mut out = vec![0u8; 44 + 20 * n]; let mut out = vec![0u8; 44 + 20 * n];
out[0..4].copy_from_slice(b"wOFF"); out[0..4].copy_from_slice(b"wOFF");
@@ -803,7 +937,13 @@ mod tests {
} }
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> { fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack } ImportRequest {
face,
file_name: "C:\\fonts\\Jost-Regular.ttf",
weight: None,
italic: None,
licence_acknowledged: ack,
}
} }
#[test] #[test]
@@ -821,9 +961,17 @@ mod tests {
assert_eq!(info.sha256, sha256_hex(&regular())); assert_eq!(info.sha256, sha256_hex(&regular()));
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
assert!(info.warnings[0].contains("rupee")); assert!(info.warnings[0].contains("rupee"));
let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap(); let poppins = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../public/fonts/poppins/Poppins-Regular.ttf"),
)
.unwrap();
let (_, with_rupee) = inspect_bytes(&poppins).unwrap(); let (_, with_rupee) = inspect_bytes(&poppins).unwrap();
assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings); assert!(
with_rupee.has_rupee && with_rupee.warnings.is_empty(),
"{:?}",
with_rupee.warnings
);
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700); assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
} }
@@ -835,7 +983,9 @@ mod tests {
let mut ttc = regular(); let mut ttc = regular();
ttc[0..4].copy_from_slice(b"ttcf"); ttc[0..4].copy_from_slice(b"ttcf");
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections")); assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
assert!(inspect_bytes(b"<!doctype html><html></html>").unwrap_err().contains("not a TrueType")); assert!(inspect_bytes(b"<!doctype html><html></html>")
.unwrap_err()
.contains("not a TrueType"));
assert!(inspect_bytes(b"short").is_err()); assert!(inspect_bytes(b"short").is_err());
let mut big = regular(); let mut big = regular();
big.resize(MAX_FONT_BYTES + 1, 0); big.resize(MAX_FONT_BYTES + 1, 0);
@@ -856,7 +1006,9 @@ mod tests {
fn fs_type_rules() { fn fs_type_rules() {
let mut restricted = regular(); let mut restricted = regular();
set_fs_type(&mut restricted, FS_RESTRICTED); set_fs_type(&mut restricted, FS_RESTRICTED);
assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted")); assert!(inspect_bytes(&restricted)
.unwrap_err()
.contains("restricted"));
// A more permissive bit alongside wins (least restrictive). // A more permissive bit alongside wins (least restrictive).
let mut both = regular(); let mut both = regular();
@@ -865,11 +1017,21 @@ mod tests {
let mut preview = regular(); let mut preview = regular();
set_fs_type(&mut preview, FS_PREVIEW_PRINT); set_fs_type(&mut preview, FS_PREVIEW_PRINT);
assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print"))); assert!(inspect_bytes(&preview)
.unwrap()
.1
.warnings
.iter()
.any(|w| w.contains("preview and print")));
let mut no_subset = regular(); let mut no_subset = regular();
set_fs_type(&mut no_subset, FS_NO_SUBSETTING); set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset"))); assert!(inspect_bytes(&no_subset)
.unwrap()
.1
.warnings
.iter()
.any(|w| w.contains("subset")));
let mut bitmap = regular(); let mut bitmap = regular();
set_fs_type(&mut bitmap, FS_BITMAP_ONLY); set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
@@ -880,13 +1042,18 @@ mod tests {
fn refuses_a_font_without_outlines() { fn refuses_a_font_without_outlines() {
let mut font = regular(); let mut font = regular();
rename_table(&mut font, b"glyf", b"XXXX"); rename_table(&mut font, b"glyf", b"XXXX");
assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines")); assert!(inspect_bytes(&font)
.unwrap_err()
.contains("no glyph outlines"));
} }
#[test] #[test]
fn unpacks_woff_to_the_same_font() { fn unpacks_woff_to_the_same_font() {
let woff = to_woff(&regular()); let woff = to_woff(&regular());
assert!(woff.len() < regular().len(), "the fixture should actually compress"); assert!(
woff.len() < regular().len(),
"the fixture should actually compress"
);
let (sfnt, info) = inspect_bytes(&woff).unwrap(); let (sfnt, info) = inspect_bytes(&woff).unwrap();
assert_eq!(info.source_format, "woff"); assert_eq!(info.source_format, "woff");
assert_eq!(info.family, "Jost"); assert_eq!(info.family, "Jost");
@@ -897,7 +1064,12 @@ mod tests {
let unpacked = Sfnt::parse(&sfnt).unwrap(); let unpacked = Sfnt::parse(&sfnt).unwrap();
assert_eq!(original.tables.len(), unpacked.tables.len()); assert_eq!(original.tables.len(), unpacked.tables.len());
for (tag, _, _) in &original.tables { for (tag, _, _) in &original.tables {
assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag)); assert_eq!(
original.table(tag),
unpacked.table(tag),
"table {}",
String::from_utf8_lossy(tag)
);
} }
let mut damaged = woff.clone(); let mut damaged = woff.clone();
let len = damaged.len(); let len = damaged.len();
@@ -909,17 +1081,28 @@ mod tests {
fn import_stores_content_addressed_and_round_trips() { fn import_stores_content_addressed_and_round_trips() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
assert_eq!(row.face, "Gotham"); assert_eq!(row.face, "Gotham");
assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf")); assert_eq!(
assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept"); (row.weight, row.style.as_str(), row.format.as_str()),
(400, "normal", "ttf")
);
assert_eq!(
row.file_name, "Jost-Regular.ttf",
"only the base name is kept"
);
assert!(!row.hidden && row.licence_ack_at.len() > 10); assert!(!row.hidden && row.licence_ack_at.len() > 10);
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256)); let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
assert_eq!(std::fs::read(&path).unwrap(), regular()); assert_eq!(std::fs::read(&path).unwrap(), regular());
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); assert_eq!(
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
regular()
);
// Importing the same file again is a no-op. // Importing the same file again is a no-op.
let again = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let again =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
assert_eq!(again.id, row.id); assert_eq!(again.id, row.id);
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1); assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
@@ -933,18 +1116,32 @@ mod tests {
fn import_overrides_and_guards() { fn import_overrides_and_guards() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
assert!(import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", false)) assert!(
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", false))
.unwrap_err() .unwrap_err()
.contains("licence")); .contains("licence")
assert!(import_font_impl(&mut conn, dir.path(), &regular(), &request("Montserrat", true)) );
assert!(import_font_impl(
&mut conn,
dir.path(),
&regular(),
&request("Montserrat", true)
)
.unwrap_err() .unwrap_err()
.contains("Now, Gotham, Open Sauce One")); .contains("Now, Gotham, Open Sauce One"));
assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing"); assert!(
!dir.path().join("fonts").exists(),
"a refused import writes nothing"
);
let mut woff2 = regular(); let mut woff2 = regular();
woff2[0..4].copy_from_slice(b"wOF2"); woff2[0..4].copy_from_slice(b"wOF2");
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err()); assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) }; let req = ImportRequest {
weight: Some(300),
italic: Some(true),
..request("Open Sauce One", true)
};
let row = import_font_impl(&mut conn, dir.path(), &regular(), &req).unwrap(); let row = import_font_impl(&mut conn, dir.path(), &regular(), &req).unwrap();
assert_eq!((row.weight, row.style.as_str()), (300, "italic")); assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
// The table itself refuses other faces. // The table itself refuses other faces.
@@ -962,15 +1159,23 @@ mod tests {
fn replacing_a_slot_removes_the_old_unreferenced_font() { fn replacing_a_slot_removes_the_old_unreferenced_font() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let first = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let first =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
// Same slot, different file (the bold file declared as 400). // Same slot, different file (the bold file declared as 400).
let req = ImportRequest { weight: Some(400), ..request("Gotham", true) }; let req = ImportRequest {
weight: Some(400),
..request("Gotham", true)
};
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap(); let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
assert_ne!(first.sha256, second.sha256); assert_ne!(first.sha256, second.sha256);
let rows = list_user_fonts_impl(&conn, true).unwrap(); let rows = list_user_fonts_impl(&conn, true).unwrap();
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
assert_eq!(rows[0].sha256, second.sha256); assert_eq!(rows[0].sha256, second.sha256);
assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists()); assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", first.sha256))
.exists());
} }
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) { fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
@@ -986,11 +1191,22 @@ mod tests {
fn remove_deletes_when_unreferenced() { fn remove_deletes_when_unreferenced() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap(); let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
assert_eq!(out, RemoveOutcome { deleted: true, hidden: false }); assert_eq!(
out,
RemoveOutcome {
deleted: true,
hidden: false
}
);
assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty()); assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty());
assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", row.sha256))
.exists());
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err()); assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err()); assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err());
} }
@@ -999,20 +1215,42 @@ mod tests {
fn remove_hides_a_font_an_issued_invoice_references() { fn remove_hides_a_font_an_issued_invoice_references() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
issue_invoice_with_prefs( issue_invoice_with_prefs(
&conn, &conn,
&format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256), &format!(
r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#,
row.sha256
),
); );
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
assert_eq!(out, RemoveOutcome { deleted: false, hidden: true }); assert_eq!(
out,
RemoveOutcome {
deleted: false,
hidden: true
}
);
// Gone from the visible list, but kept and still served so the invoice can re-render. // Gone from the visible list, but kept and still served so the invoice can re-render.
assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty()); assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1); assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); assert_eq!(
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
regular()
);
// The slot is free again: importing a new font for it works, and the hidden one stays. // The slot is free again: importing a new font for it works, and the hidden one stays.
let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); let fresh = import_font_impl(
&mut conn,
dir.path(),
&bold(),
&ImportRequest {
weight: Some(400),
..request("Gotham", true)
},
)
.unwrap();
assert_ne!(fresh.sha256, row.sha256); assert_ne!(fresh.sha256, row.sha256);
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2); assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
} }
@@ -1021,10 +1259,27 @@ mod tests {
fn replacing_a_referenced_font_hides_it_instead_of_deleting() { fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let row =
issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256)); import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); issue_invoice_with_prefs(
assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); &conn,
&format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
);
import_font_impl(
&mut conn,
dir.path(),
&bold(),
&ImportRequest {
weight: Some(400),
..request("Gotham", true)
},
)
.unwrap();
assert!(dir
.path()
.join("fonts")
.join(format!("{}.ttf", row.sha256))
.exists());
let all = list_user_fonts_impl(&conn, true).unwrap(); let all = list_user_fonts_impl(&conn, true).unwrap();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1); assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
@@ -1034,22 +1289,38 @@ mod tests {
fn a_file_shared_by_two_faces_survives_removing_one() { fn a_file_shared_by_two_faces_survives_removing_one() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let a = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap(); let a =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let b = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap(); let b = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
assert_eq!(a.sha256, b.sha256); assert_eq!(a.sha256, b.sha256);
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap(); remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); assert!(dir
.path()
.join("fonts")
.join(format!("{}.ttf", a.sha256))
.exists());
remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap(); remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap();
assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", a.sha256))
.exists());
} }
#[test] #[test]
fn bytes_are_verified_and_the_hash_shape_checked() { fn bytes_are_verified_and_the_hash_shape_checked() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap(); let row =
std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap(); import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted")); std::fs::write(
dir.path().join("fonts").join(format!("{}.ttf", row.sha256)),
b"tampered",
)
.unwrap();
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256)
.unwrap_err()
.contains("corrupted"));
assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err()); assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err());
} }
@@ -1061,11 +1332,18 @@ mod tests {
h.insert("x-weight".into(), "700".into()); h.insert("x-weight".into(), "700".into());
h.insert("x-style".into(), "italic".into()); h.insert("x-style".into(), "italic".into());
let req = parse_import_headers(&h, "f.ttf").unwrap(); let req = parse_import_headers(&h, "f.ttf").unwrap();
assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true)); assert_eq!(
(req.weight, req.italic, req.licence_acknowledged),
(Some(700), Some(true), true)
);
h.insert("x-style".into(), "oblique".into()); h.insert("x-style".into(), "oblique".into());
assert!(parse_import_headers(&h, "f.ttf").is_err()); assert!(parse_import_headers(&h, "f.ttf").is_err());
h.remove("x-licence-ack"); h.remove("x-licence-ack");
h.insert("x-style".into(), "".into()); h.insert("x-style".into(), "".into());
assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged); assert!(
!parse_import_headers(&h, "f.ttf")
.unwrap()
.licence_acknowledged
);
} }
} }
+186 -53
View File
@@ -1,11 +1,12 @@
use super::assets::relative_asset_path; use super::assets::relative_asset_path;
use super::payments::{derive_summary, summary_for, today_ist};
use super::series::{validate_series_format, MAX_NUMBER_LEN}; use super::series::{validate_series_format, MAX_NUMBER_LEN};
use super::settings::{map_bank, map_settings, SETTINGS_COLS}; use super::settings::{map_bank, map_settings, SETTINGS_COLS};
use crate::db::format_number; use crate::db::format_number;
use crate::gst::{self, TaxType}; use crate::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{ use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings, BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings,
}; };
use crate::AppState; use crate::AppState;
use chrono::NaiveDate; use chrono::NaiveDate;
@@ -140,7 +141,9 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
match item.mode.as_str() { match item.mode.as_str() {
"rate" => { "rate" => {
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 { if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
return Err(format!("Line {n}: rate and quantity must be positive numbers")); return Err(format!(
"Line {n}: rate and quantity must be positive numbers"
));
} }
if item.quantity > MAX_LINE_RUPEES { if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large")); return Err(format!("Line {n}: quantity is too large"));
@@ -271,8 +274,9 @@ pub fn issue_invoice_impl(
.map_err(db)? .map_err(db)?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?; .ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
validate_series_format(&prefix, padding) validate_series_format(&prefix, padding).map_err(|e| {
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?; format!("The active invoice series cannot be used: {e}. Start a new series.")
})?;
let number = format_number(&prefix, padding, next_number); let number = format_number(&prefix, padding, next_number);
if number.len() > MAX_NUMBER_LEN { if number.len() > MAX_NUMBER_LEN {
return Err(format!( return Err(format!(
@@ -296,10 +300,16 @@ pub fn issue_invoice_impl(
}; };
let pos = input.place_of_supply_state_code.trim().to_string(); let pos = input.place_of_supply_state_code.trim().to_string();
if !pos.is_empty() && !gst::state_exists(&pos) { if !pos.is_empty() && !gst::state_exists(&pos) {
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code")); return Err(format!(
"Place of supply \"{pos}\" is not a valid GST state code"
));
} }
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos); let derived = gst::derive_tax_type(
&settings.gst_registration,
&settings.vendor_state_code,
&pos,
);
if input.tax_type != derived.as_str() { if input.tax_type != derived.as_str() {
return Err(format!( return Err(format!(
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}", "Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
@@ -309,7 +319,11 @@ pub fn issue_invoice_impl(
if pos.is_empty() { &settings.vendor_state_code } else { &pos }, if pos.is_empty() { &settings.vendor_state_code } else { &pos },
)); ));
} }
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate }; let tax_rate = if derived == TaxType::None {
0.0
} else {
input.tax_rate
};
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) { if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
return Err("Tax rate must be between 0 and 100".into()); return Err("Tax rate must be between 0 and 100".into());
} }
@@ -330,9 +344,9 @@ pub fn issue_invoice_impl(
let snapshot = vendor_snapshot(&settings); let snapshot = vendor_snapshot(&settings);
let bank = bank_snapshot(&tx, input.bank_account_id)?; let bank = bank_snapshot(&tx, input.bank_account_id)?;
let signature_path = match input.signature_path.as_deref().map(str::trim) { let signature_path = match input.signature_path.as_deref().map(str::trim) {
Some(p) if !p.is_empty() => Some( Some(p) if !p.is_empty() => {
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?, Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?)
), }
_ => None, _ => None,
}; };
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?; let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
@@ -445,7 +459,10 @@ pub fn issue_invoice_impl(
.map_err(db)?; .map_err(db)?;
if let Some(draft_id) = input.draft_id { if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id]) tx.execute(
"DELETE FROM invoice_drafts WHERE id = ?1",
params![draft_id],
)
.map_err(db)?; .map_err(db)?;
} }
@@ -464,9 +481,17 @@ pub fn issue_invoice(
} }
/// Issued invoices are never deleted: cancelling keeps the row and its number. /// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> { pub fn cancel_invoice_impl(
conn: &mut Connection,
id: i64,
reason: &str,
) -> Result<Invoice, String> {
let payments: i64 = conn let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0)) .query_row(
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
params![id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if payments > 0 { if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into()); return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
@@ -482,7 +507,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if changed == 0 { if changed == 0 {
let status: Option<String> = conn let status: Option<String> = conn
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) .query_row(
"SELECT status FROM invoices WHERE id = ?1",
params![id],
|r| r.get(0),
)
.optional() .optional()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
return Err(match status { return Err(match status {
@@ -525,7 +554,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
let status: String = row.get(6)?; let status: String = row.get(6)?;
let due_date: String = row.get(3)?; let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total); let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today); let payment = derive_summary(
&status,
total_paise,
row.get(9)?,
row.get(10)?,
&due_date,
&today,
);
Ok(InvoiceSummary { Ok(InvoiceSummary {
id: row.get(0)?, id: row.get(0)?,
number: row.get(1)?, number: row.get(1)?,
@@ -566,7 +602,8 @@ pub struct InvoiceLedgerRow {
} }
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> { pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new(); let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
std::collections::HashMap::new();
{ {
let mut stmt = conn let mut stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id") .prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
@@ -666,11 +703,17 @@ pub fn save_draft_impl(
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> { pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
let mut stmt = conn let mut stmt = conn
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC") .prepare(
"SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC",
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map([], |r| { .query_map([], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)) Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
}) })
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>() .collect::<rusqlite::Result<Vec<_>>>()
@@ -682,7 +725,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
DraftSummary { DraftSummary {
id, id,
updated_at, updated_at,
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from), client_name: json
.get("clientName")
.and_then(|v| v.as_str())
.map(String::from),
total: json.get("total").and_then(|v| v.as_f64()), total: json.get("total").and_then(|v| v.as_f64()),
} }
}) })
@@ -691,7 +737,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> { pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
let text: String = conn let text: String = conn
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0)) .query_row(
"SELECT payload FROM invoice_drafts WHERE id = ?1",
params![id],
|r| r.get(0),
)
.optional() .optional()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "Draft not found".to_string())?; .ok_or_else(|| "Draft not found".to_string())?;
@@ -765,19 +815,28 @@ mod tests {
} }
fn next_number(conn: &Connection) -> i64 { fn next_number(conn: &Connection) -> i64 {
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) conn.query_row(
"SELECT next_number FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap() .unwrap()
} }
fn invoice_count(conn: &Connection) -> i64 { fn invoice_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap() conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
.unwrap()
} }
#[test] #[test]
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() { fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
let mut conn = registered(); let mut conn = registered();
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap(); let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap(); conn.execute(
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
params![first.id],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')", "INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
params![first.id], params![first.id],
@@ -791,7 +850,10 @@ mod tests {
assert_eq!(row.doc_type, "tax_invoice"); assert_eq!(row.doc_type, "tax_invoice");
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]); assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
assert_eq!(row.taxable_paise, 731_000); assert_eq!(row.taxable_paise, 731_000);
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0)); assert_eq!(
(row.cgst_paise, row.sgst_paise, row.igst_paise),
(65_790, 65_790, 0)
);
assert!(!row.reverse_charge); assert!(!row.reverse_charge);
} }
@@ -862,7 +924,10 @@ mod tests {
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})), input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
) )
.unwrap_err(); .unwrap_err();
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}"); assert!(
err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""),
"{err}"
);
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err(); let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
assert!(err.contains("does not match"), "{err}"); assert!(err.contains("does not match"), "{err}");
assert_eq!(next_number(&conn), 1); assert_eq!(next_number(&conn), 1);
@@ -872,7 +937,11 @@ mod tests {
#[test] #[test]
fn unregistered_supplier_issues_a_plain_invoice_without_tax() { fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap(); conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'",
[],
)
.unwrap();
let inv = issue( let inv = issue(
&mut conn, &mut conn,
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})), input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
@@ -929,7 +998,8 @@ mod tests {
assert!(inv.amount_in_words.contains("Eight Thousand")); assert!(inv.amount_in_words.contains("Eight Thousand"));
// Later settings edits do not touch the stored snapshot. // Later settings edits do not touch the stored snapshot.
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap(); conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", [])
.unwrap();
let again = fetch_invoice(&conn, inv.id).unwrap(); let again = fetch_invoice(&conn, inv.id).unwrap();
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot); assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
} }
@@ -937,7 +1007,9 @@ mod tests {
#[test] #[test]
fn bank_snapshot_is_built_from_the_bank_row() { fn bank_snapshot_is_built_from_the_bank_row() {
let mut conn = registered(); let mut conn = registered();
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); let bank_id: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap(); let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap(); let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
assert_eq!(bank["bankName"], "Test Bank"); assert_eq!(bank["bankName"], "Test Bank");
@@ -956,15 +1028,22 @@ mod tests {
params![format!("INV/{year}-001")], params![format!("INV/{year}-001")],
) )
.unwrap(); .unwrap();
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", []) conn.execute(
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
[],
)
.unwrap(); .unwrap();
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err(); let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
assert!(err.contains("already exists"), "{err}"); assert!(err.contains("already exists"), "{err}");
assert_eq!(next_number(&conn), 1); assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 1); assert_eq!(invoice_count(&conn), 1);
// The client insert and the draft delete were rolled back too. // The client insert and the draft delete were rolled back too.
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap(); let clients: i64 = conn
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap(); .query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0))
.unwrap();
let drafts: i64 = conn
.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0))
.unwrap();
assert_eq!((clients, drafts), (0, 1)); assert_eq!((clients, drafts), (0, 1));
} }
@@ -979,10 +1058,19 @@ mod tests {
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}), json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
"greater than zero", "greater than zero",
), ),
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"), (
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"), json!({"items": [{"mode": "fixed", "amount": -1.0}]}),
"zero or more",
),
(
json!({"items": [{"mode": "weird", "amount": 1.0}]}),
"unknown line type",
),
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"), (json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"), (
json!({"placeOfSupplyStateCode": "99"}),
"not a valid GST state code",
),
(json!({"taxRate": 150.0}), "between 0 and 100"), (json!({"taxRate": 150.0}), "between 0 and 100"),
(json!({"signaturePath": "../voiced.db"}), "Signature image"), (json!({"signaturePath": "../voiced.db"}), "Signature image"),
]; ];
@@ -1001,31 +1089,58 @@ mod tests {
#[test] #[test]
fn supplier_settings_are_validated() { fn supplier_settings_are_validated() {
let mut conn = registered(); let mut conn = registered();
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap(); conn.execute(
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit")); "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'",
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap(); [],
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN")); )
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap(); .unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29")); assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("check digit"));
conn.execute("UPDATE app_settings SET vendor_gstin = ''", [])
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("needs a GSTIN"));
conn.execute(
"UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("state code 29"));
conn.execute( conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'", "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
[], [],
) )
.unwrap(); .unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN")); assert!(issue(&mut conn, input(json!({})))
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", []) .unwrap_err()
.contains("PAN"));
conn.execute(
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
[],
)
.unwrap(); .unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code")); assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("state code"));
assert_eq!(next_number(&conn), 1); assert_eq!(next_number(&conn), 1);
} }
#[test] #[test]
fn legacy_series_that_break_the_rules_are_refused() { fn legacy_series_that_break_the_rules_are_refused() {
let mut conn = registered(); let mut conn = registered();
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap(); conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", [])
.unwrap();
let err = issue(&mut conn, input(json!({}))).unwrap_err(); let err = issue(&mut conn, input(json!({}))).unwrap_err();
assert!(err.contains("active invoice series"), "{err}"); assert!(err.contains("active invoice series"), "{err}");
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap(); conn.execute(
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({}))).is_err()); assert!(issue(&mut conn, input(json!({}))).is_err());
// A counter that outgrows its padding can push the number past 16 characters. // A counter that outgrows its padding can push the number past 16 characters.
conn.execute( conn.execute(
@@ -1033,7 +1148,9 @@ mod tests {
[], [],
) )
.unwrap(); .unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16")); assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("longer than 16"));
assert_eq!(invoice_count(&conn), 0); assert_eq!(invoice_count(&conn), 0);
} }
@@ -1061,7 +1178,10 @@ mod tests {
&json!({"theme": "plain"}), &json!({"theme": "plain"}),
) )
.unwrap(); .unwrap();
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png")); assert_eq!(
inv.signature_path.as_deref(),
Some("assets/signature-1.png")
);
let prefs: String = conn let prefs: String = conn
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0)) .query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
.unwrap(); .unwrap();
@@ -1082,8 +1202,14 @@ mod tests {
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err(); let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
assert!(err.contains("cancelled"), "{err}"); assert!(err.contains("cancelled"), "{err}");
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found")); assert!(cancel_invoice_impl(&mut conn, 999, "")
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap(); .unwrap_err()
.contains("not found"));
conn.execute(
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
params![inv.id],
)
.unwrap();
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err()); assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
// The number is never reused. // The number is never reused.
@@ -1095,10 +1221,16 @@ mod tests {
#[test] #[test]
fn drafts_round_trip() { fn drafts_round_trip() {
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap(); let a =
save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap(); let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
assert_ne!(a, b); assert_ne!(a, b);
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap(); let c = save_draft_impl(
&conn,
Some(a),
&json!({"clientName": "Acme 2", "total": 10}),
)
.unwrap();
assert_eq!(c, a); assert_eq!(c, a);
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err()); assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
@@ -1111,7 +1243,8 @@ mod tests {
let blank = list.iter().find(|d| d.id == b).unwrap(); let blank = list.iter().find(|d| d.id == b).unwrap();
assert_eq!((blank.client_name.clone(), blank.total), (None, None)); assert_eq!((blank.client_name.clone(), blank.total), (None, None));
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap(); conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a])
.unwrap();
assert!(get_draft_impl(&conn, a).is_err()); assert!(get_draft_impl(&conn, a).is_err());
} }
} }
+68 -18
View File
@@ -21,14 +21,22 @@ pub struct LogoAsset {
} }
fn sha8(bytes: &[u8]) -> String { fn sha8(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect() Sha256::digest(bytes)
.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect()
} }
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset /// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without /// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at /// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
/// older logo files. /// older logo files.
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> { fn store_logo(
data_dir: &Path,
bytes: &[u8],
existing_original: Option<String>,
) -> Result<LogoAsset, String> {
let derived = process_logo(bytes).map_err(|e| e.to_string())?; let derived = process_logo(bytes).map_err(|e| e.to_string())?;
let id = sha8(bytes); let id = sha8(bytes);
let original_path = match existing_original { let original_path = match existing_original {
@@ -38,17 +46,28 @@ fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>)
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)? write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
} }
}; };
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?; let print_path = write_named_asset(
data_dir,
&format!("logo-{id}-print.png"),
&derived.print_png,
)?;
let knockout_path = derived let knockout_path = derived
.knockout_png .knockout_png
.as_deref() .as_deref()
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png)) .map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
.transpose()?; .transpose()?;
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics }) Ok(LogoAsset {
original_path,
print_path,
knockout_path,
metrics: derived.metrics,
})
} }
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> { pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len(); let len = std::fs::metadata(source_path)
.map_err(|e| e.to_string())?
.len();
if len > MAX_INPUT_BYTES as u64 { if len > MAX_INPUT_BYTES as u64 {
return Err(crate::logo::LogoError::TooLarge.to_string()); return Err(crate::logo::LogoError::TooLarge.to_string());
} }
@@ -61,7 +80,9 @@ pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<Log
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 { if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
return Err(crate::logo::LogoError::TooLarge.to_string()); return Err(crate::logo::LogoError::TooLarge.to_string());
} }
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?; let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
store_logo(data_dir, &bytes, None) store_logo(data_dir, &bytes, None)
} }
@@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
/// original. Returns whether anything changed. /// original. Returns whether anything changed.
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> { pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
let (logo_path, meta): (Option<String>, String) = conn let (logo_path, meta): (Option<String>, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?)) "SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
}) [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else { let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
return Ok(false); return Ok(false);
@@ -86,11 +109,20 @@ pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bo
} }
let file = resolve_asset(data_dir, &logo_path)?; let file = resolve_asset(data_dir, &logo_path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?; let asset = store_logo(
data_dir,
&bytes,
Some(relative_asset_path(data_dir, &logo_path)?),
)?;
conn.execute( conn.execute(
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3, "UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
logo_meta = ?4 WHERE id = 1", logo_meta = ?4 WHERE id = 1",
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?], params![
asset.print_path,
asset.original_path,
asset.knockout_path,
metrics_json(&asset.metrics)?
],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(true) Ok(true)
@@ -161,7 +193,9 @@ mod tests {
} }
} }
let mut out = Vec::new(); let mut out = Vec::new();
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap(); DynamicImage::ImageRgba8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out out
} }
@@ -180,16 +214,30 @@ mod tests {
let bytes = padded_png(); let bytes = padded_png();
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap(); let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
let id = sha8(&bytes); let id = sha8(&bytes);
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png")); assert_eq!(
asset.original_path,
format!("assets/logo-{id}-original.png")
);
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png")); assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str())); assert_eq!(
asset.knockout_path.as_deref(),
Some(format!("assets/logo-{id}-knockout.png").as_str())
);
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20)); assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes); assert_eq!(
std::fs::read(dir.path().join(&asset.original_path)).unwrap(),
bytes
);
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] { for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
assert!(resolve_asset(dir.path(), rel).is_ok()); assert!(resolve_asset(dir.path(), rel).is_ok());
} }
// The same bytes land on the same names. // The same bytes land on the same names.
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path); assert_eq!(
import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes))
.unwrap()
.print_path,
asset.print_path
);
let json = serde_json::to_value(&asset).unwrap(); let json = serde_json::to_value(&asset).unwrap();
assert_eq!(json["metrics"]["kind"], "wordmark"); assert_eq!(json["metrics"]["kind"], "wordmark");
assert_eq!(json["metrics"]["opaqueBackground"], false); assert_eq!(json["metrics"]["opaqueBackground"], false);
@@ -220,7 +268,8 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap(); let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap(); conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy])
.unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap()); assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
let (logo, original, knockout, meta) = logo_columns(&conn); let (logo, original, knockout, meta) = logo_columns(&conn);
@@ -243,7 +292,8 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap(); let conn = crate::db::open_in_memory().unwrap();
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap()); assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap(); conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", [])
.unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err()); assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
// Left untouched so a later start can retry. // Left untouched so a later start can retry.
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png")); assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
+84 -20
View File
@@ -53,7 +53,11 @@ pub struct PaymentInput {
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates. /// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
pub fn today_ist() -> String { pub fn today_ist() -> String {
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset"); let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string() Utc::now()
.with_timezone(&ist)
.date_naive()
.format("%Y-%m-%d")
.to_string()
} }
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none` /// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
@@ -112,7 +116,14 @@ pub fn summary_for(
params![invoice_id], params![invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)), |r| Ok((r.get(0)?, r.get(1)?)),
)?; )?;
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist())) Ok(derive_summary(
status,
rupees_to_paise(total),
paid,
tds,
due_date,
&today_ist(),
))
} }
fn rupees(paise: i64) -> String { fn rupees(paise: i64) -> String {
@@ -130,7 +141,9 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
reference: r.get(6)?, reference: r.get(6)?,
notes: r.get(7)?, notes: r.get(7)?,
created_at: r.get(8)?, created_at: r.get(8)?,
erpnext_payment_entry: r.get::<_, Option<String>>(9)?.filter(|e| !e.trim().is_empty()), erpnext_payment_entry: r
.get::<_, Option<String>>(9)?
.filter(|e| !e.trim().is_empty()),
}) })
} }
@@ -165,7 +178,9 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
.map_err(db)? .map_err(db)?
.ok_or_else(|| "Invoice not found".to_string())?; .ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" { if status != "issued" {
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})")); return Err(format!(
"Payments can only be recorded on an issued invoice (this one is {status})"
));
} }
let total_paise = rupees_to_paise(total); let total_paise = rupees_to_paise(total);
let settled: i64 = tx let settled: i64 = tx
@@ -203,7 +218,11 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
.map_err(db)?; .map_err(db)?;
let id = tx.last_insert_rowid(); let id = tx.last_insert_rowid();
let payment = tx let payment = tx
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment) .query_row(
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
params![id],
map_payment,
)
.map_err(db)?; .map_err(db)?;
tx.commit().map_err(db)?; tx.commit().map_err(db)?;
Ok(payment) Ok(payment)
@@ -270,7 +289,9 @@ mod tests {
"items": [{"description": "Design", "mode": "fixed", "amount": amount}], "items": [{"description": "Design", "mode": "fixed", "amount": amount}],
})) }))
.unwrap(); .unwrap();
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
.unwrap()
.id
} }
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput { fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
@@ -287,9 +308,11 @@ mod tests {
fn summary(conn: &Connection, id: i64) -> PaymentSummary { fn summary(conn: &Connection, id: i64) -> PaymentSummary {
let (status, total, due): (String, f64, String) = conn let (status, total, due): (String, f64, String) = conn
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?, r.get(2)?)) "SELECT status, total, due_date FROM invoices WHERE id = ?1",
}) params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap(); .unwrap();
summary_for(conn, id, &status, total, &due).unwrap() summary_for(conn, id, &status, total, &due).unwrap()
} }
@@ -303,7 +326,10 @@ mod tests {
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
let s = summary(&conn, id); let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000)); assert_eq!(
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
("partially_paid", 40_000, 60_000)
);
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
let s = summary(&conn, id); let s = summary(&conn, id);
@@ -317,7 +343,15 @@ mod tests {
// 10% TDS: the client pays 900.00 in cash and deducts 100.00. // 10% TDS: the client pays 900.00 in cash and deducts 100.00.
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap(); record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
let s = summary(&conn, id); let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0)); assert_eq!(
(
s.payment_status.as_str(),
s.paid_paise,
s.tds_paise,
s.balance_paise
),
("paid", 90_000, 10_000, 0)
);
// TDS alone is a valid payment too. // TDS alone is a valid payment too.
let id2 = issued(&mut conn, 500.0, "2999-01-01"); let id2 = issued(&mut conn, 500.0, "2999-01-01");
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap(); record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
@@ -329,13 +363,18 @@ mod tests {
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01"); let id = issued(&mut conn, 1000.0, "2999-01-01");
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err(); let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}"); assert!(
err.contains("more than the outstanding balance of ₹1000.00"),
"{err}"
);
// Cash plus TDS is what counts. // Cash plus TDS is what counts.
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err()); assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err(); let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
assert!(err.contains("₹500.00"), "{err}"); assert!(err.contains("₹500.00"), "{err}");
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap(); let n: i64 = conn
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1); assert_eq!(n, 1);
} }
@@ -374,7 +413,11 @@ mod tests {
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0)); assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
let id2 = issued(&mut conn, 1000.0, "2999-01-01"); let id2 = issued(&mut conn, 1000.0, "2999-01-01");
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap(); conn.execute(
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
params![id2],
)
.unwrap();
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err()); assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
} }
@@ -397,27 +440,38 @@ mod tests {
assert_eq!(summary(&conn, id).payment_status, "paid"); assert_eq!(summary(&conn, id).payment_status, "paid");
delete_payment_impl(&conn, full.id).unwrap(); delete_payment_impl(&conn, full.id).unwrap();
let s = summary(&conn, id); let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000)); assert_eq!(
(s.payment_status.as_str(), s.balance_paise),
("unpaid", 100_000)
);
assert!(list_payments_impl(&conn, id).unwrap().is_empty()); assert!(list_payments_impl(&conn, id).unwrap().is_empty());
assert!(delete_payment_impl(&conn, full.id).is_err()); assert!(delete_payment_impl(&conn, full.id).is_err());
} }
#[test] #[test]
fn overdue_is_derived_from_due_date_and_balance() { fn overdue_is_derived_from_due_date_and_balance() {
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10"); let d = |due: &str, paid: i64, tds: i64| {
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
};
// Due today is not overdue yet; the day after is. // Due today is not overdue yet; the day after is.
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid"); assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue"); assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
// Overdue wins over partially paid; the paid amount is still reported. // Overdue wins over partially paid; the paid amount is still reported.
let s = d("2026-05-09", 10_000, 0); let s = d("2026-05-09", 10_000, 0);
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000)); assert_eq!(
(s.payment_status.as_str(), s.paid_paise),
("overdue", 10_000)
);
// A settled invoice is never overdue. // A settled invoice is never overdue.
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid"); assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
// No usable due date means never overdue. // No usable due date means never overdue.
assert_eq!(d("", 0, 0).payment_status, "unpaid"); assert_eq!(d("", 0, 0).payment_status, "unpaid");
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid"); assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
// Cancelled invoices are not owed. // Cancelled invoices are not owed.
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0); assert_eq!(
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
0
);
} }
#[test] #[test]
@@ -429,7 +483,10 @@ mod tests {
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
let s = summary(&conn, id); let s = summary(&conn, id);
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid")); assert_eq!(
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
(9999, 0, "paid")
);
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap(); let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
assert_eq!(inv.total_paise, 9999); assert_eq!(inv.total_paise, 9999);
} }
@@ -445,7 +502,14 @@ mod tests {
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap(); let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap(); let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
let ra = get(a); let ra = get(a);
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000)); assert_eq!(
(
ra.payment.paid_paise,
ra.payment.tds_paise,
ra.payment.balance_paise
),
(40_000, 5_000, 55_000)
);
assert_eq!(ra.payment.payment_status, "overdue"); assert_eq!(ra.payment.payment_status, "overdue");
assert_eq!(ra.total_paise, 100_000); assert_eq!(ra.total_paise, 100_000);
assert_eq!(get(b).payment.payment_status, "paid"); assert_eq!(get(b).payment.payment_status, "paid");
+4 -1
View File
@@ -136,7 +136,10 @@ mod tests {
let saved = save_preset_impl(&conn, p).unwrap(); let saved = save_preset_impl(&conn, p).unwrap();
assert_eq!(saved.description, "Voice-over session"); assert_eq!(saved.description, "Voice-over session");
assert_eq!(saved.hsn_sac, "998397"); assert_eq!(saved.hsn_sac, "998397");
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050)); assert_eq!(
(saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise),
("rate", "hour", 150_050)
);
let mut edit = saved.clone(); let mut edit = saved.clone();
edit.rate_paise = 200_000; edit.rate_paise = 200_000;
+20 -4
View File
@@ -154,7 +154,11 @@ mod tests {
assert_eq!(s.prefix, "FY27/"); assert_eq!(s.prefix, "FY27/");
assert_eq!(s.next_invoice_number, "FY27/0001"); assert_eq!(s.next_invoice_number, "FY27/0001");
let active: i64 = c let active: i64 = c
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) .query_row(
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(active, 1); assert_eq!(active, 1);
} }
@@ -163,12 +167,20 @@ mod tests {
fn invalid_series_leaves_the_active_one_alone() { fn invalid_series_leaves_the_active_one_alone() {
let mut c = conn(); let mut c = conn();
let before: i64 = c let before: i64 = c
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) .query_row(
"SELECT id FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err()); assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err()); assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
let after: i64 = c let after: i64 = c
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) .query_row(
"SELECT id FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(before, after); assert_eq!(before, after);
} }
@@ -184,7 +196,11 @@ mod tests {
.unwrap(); .unwrap();
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err()); assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
let active: i64 = c let active: i64 = c
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) .query_row(
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(active, 1); assert_eq!(active, 1);
} }
+37 -9
View File
@@ -142,8 +142,14 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3, r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#, account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
params![ params![
bank.label, bank.bank_name, bank.account_name, bank.account_no, bank.label,
bank.branch, bank.ifsc, is_default, id bank.bank_name,
bank.account_name,
bank.account_no,
bank.branch,
bank.ifsc,
is_default,
id
], ],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -155,8 +161,13 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
(label, bank_name, account_name, account_no, branch, ifsc, is_default) (label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
params![ params![
bank.label, bank.bank_name, bank.account_name, bank.account_no, bank.label,
bank.branch, bank.ifsc, is_default bank.bank_name,
bank.account_name,
bank.account_no,
bank.branch,
bank.ifsc,
is_default
], ],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -208,10 +219,16 @@ pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
tx.execute("UPDATE bank_accounts SET is_default = 0", []) tx.execute("UPDATE bank_accounts SET is_default = 0", [])
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(next) = next { if let Some(next) = next {
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next]) tx.execute(
"UPDATE bank_accounts SET is_default = 1 WHERE id = ?1",
params![next],
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next]) tx.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![next],
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
tx.commit().map_err(|e| e.to_string()) tx.commit().map_err(|e| e.to_string())
@@ -238,12 +255,20 @@ mod tests {
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) { fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
let setting = conn let setting = conn
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0)) .query_row(
"SELECT default_bank_id FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
let mut stmt = conn let mut stmt = conn
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id") .prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
.unwrap(); .unwrap();
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect(); let flagged = stmt
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
(setting, flagged) (setting, flagged)
} }
@@ -259,7 +284,10 @@ mod tests {
.unwrap() .unwrap()
}; };
assert_eq!(read(&conn).render_prefs, ""); assert_eq!(read(&conn).render_prefs, "");
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"]) conn.execute(
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
params!["{\"version\":1}"],
)
.unwrap(); .unwrap();
let settings = read(&conn); let settings = read(&conn);
assert_eq!(settings.render_prefs, "{\"version\":1}"); assert_eq!(settings.render_prefs, "{\"version\":1}");
+189 -50
View File
@@ -482,7 +482,10 @@ pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
[], [],
)?; )?;
let bank_id = conn.last_insert_rowid(); let bank_id = conn.last_insert_rowid();
conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?; conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
)?;
Ok(()) Ok(())
} }
@@ -542,8 +545,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
} }
let snapshots: Vec<(i64, String)> = { let snapshots: Vec<(i64, String)> = {
let mut stmt = let mut stmt = tx.prepare(
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?; "SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<rusqlite::Result<_>>()? rows.collect::<rusqlite::Result<_>>()?
}; };
@@ -574,7 +578,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001". /// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) format!(
"{}{:0width$}",
prefix,
number,
width = padding.max(1) as usize
)
} }
#[cfg(test)] #[cfg(test)]
@@ -682,7 +691,8 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
"#; "#;
fn user_version(conn: &Connection) -> i64 { fn user_version(conn: &Connection) -> i64 {
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap() conn.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap()
} }
fn backups_in(dir: &Path) -> Vec<PathBuf> { fn backups_in(dir: &Path) -> Vec<PathBuf> {
@@ -724,7 +734,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
fn m4_adds_archive_and_export_dir_columns() { fn m4_adds_archive_and_export_dir_columns() {
let conn = open_in_memory().unwrap(); let conn = open_in_memory().unwrap();
let dir: String = conn let dir: String = conn
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) .query_row(
"SELECT last_export_dir FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(dir, ""); assert_eq!(dir, "");
// The columns exist and are nullable on invoices (no rows needed to prove it). // The columns exist and are nullable on invoices (no rows needed to prove it).
@@ -745,14 +759,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
fn render_prefs_column_defaults_to_empty_and_round_trips() { fn render_prefs_column_defaults_to_empty_and_round_trips() {
let conn = open_in_memory().unwrap(); let conn = open_in_memory().unwrap();
let initial: String = conn let initial: String = conn
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) .query_row(
"SELECT render_prefs FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(initial, ""); assert_eq!(initial, "");
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#; let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap(); conn.execute(
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
[json],
)
.unwrap();
let stored: String = conn let stored: String = conn
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) .query_row(
"SELECT render_prefs FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(stored, json); assert_eq!(stored, json);
} }
@@ -779,15 +805,21 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.to_latest(&mut old) .to_latest(&mut old)
.unwrap(); .unwrap();
seed(&old).unwrap(); seed(&old).unwrap();
old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap(); old.execute(
"UPDATE app_settings SET logo_path = 'assets/logo-old.png'",
[],
)
.unwrap();
assert_eq!(user_version(&old), 4); assert_eq!(user_version(&old), 4);
} }
let conn = open(&path, &dir.path().join("backups")).unwrap(); let conn = open(&path, &dir.path().join("backups")).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION); assert_eq!(user_version(&conn), LATEST_VERSION);
let (logo, meta): (String, String) = conn let (logo, meta): (String, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?)) "SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
}) [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap(); .unwrap();
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", "")); assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1); assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
@@ -808,7 +840,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
) )
.unwrap(); .unwrap();
let (cat, terms): (String, Option<i64>) = conn let (cat, terms): (String, Option<i64>) = conn
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?))) .query_row(
"SELECT gst_category, payment_terms_days FROM clients",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap(); .unwrap();
assert_eq!((cat.as_str(), terms), ("unregistered", None)); assert_eq!((cat.as_str(), terms), ("unregistered", None));
// The category CHECK rejects unknown values. // The category CHECK rejects unknown values.
@@ -820,7 +856,14 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
#[test] #[test]
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() { fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
let mut conn = Connection::open_in_memory().unwrap(); let mut conn = Connection::open_in_memory().unwrap();
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]; let all = vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
];
Migrations::new(all).to_latest(&mut conn).unwrap(); Migrations::new(all).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 6); assert_eq!(user_version(&conn), 6);
conn.execute( conn.execute(
@@ -846,7 +889,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.collect::<Result<_, _>>() .collect::<Result<_, _>>()
.unwrap(); .unwrap();
assert_eq!(rows[0].1, "Line one\nLine two"); assert_eq!(rows[0].1, "Line one\nLine two");
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1")); assert_eq!(
(rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()),
("29ABCDE1234F1Z5", "29", "PO-1")
);
assert_eq!(rows[0].5, "registered_regular"); assert_eq!(rows[0].5, "registered_regular");
assert_eq!(rows[0].6, ""); assert_eq!(rows[0].6, "");
assert_eq!(rows[1].5, "unregistered"); assert_eq!(rows[1].5, "unregistered");
@@ -864,25 +910,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
) )
.unwrap(); .unwrap();
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1)); assert_eq!(
(naming.as_str(), submit, attach, auto_push, create_customers),
("mirror", 0, 1, 0, 1)
);
assert_eq!(secret, ""); assert_eq!(secret, "");
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok()); assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap(); let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 1); assert_eq!(rows, 1);
// Single row only. // Single row only.
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err()); assert!(conn
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
.is_err());
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync") conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
.unwrap(); .unwrap();
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap(); conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap(); .unwrap();
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap(); conn.prepare("SELECT erpnext_payment_entry FROM payments")
.unwrap();
conn.prepare("SELECT erpnext_item_code FROM item_presets")
.unwrap();
} }
#[test] #[test]
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() { fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
let mut conn = Connection::open_in_memory().unwrap(); let mut conn = Connection::open_in_memory().unwrap();
let v9 = vec![ let v9 = vec![
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
]; ];
Migrations::new(v9).to_latest(&mut conn).unwrap(); Migrations::new(v9).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 9); assert_eq!(user_version(&conn), 9);
@@ -902,37 +966,70 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
migrations().to_latest(&mut conn).unwrap(); migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION); assert_eq!(user_version(&conn), LATEST_VERSION);
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| { conn.query_row(
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) "SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
}) [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap(); .unwrap();
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None)); assert_eq!(
(name.as_str(), gstin.as_str(), customer, address),
("Acme", "29ABCDE1234F1Z5", None, None)
);
let (desc, rate, code): (String, i64, Option<String>) = conn let (desc, rate, code): (String, i64, Option<String>) = conn
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?, r.get(2)?)) "SELECT description, rate_paise, erpnext_item_code FROM item_presets",
}) [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap(); .unwrap();
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None)); assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
let (paid, entry): (i64, Option<String>) = conn let (paid, entry): (i64, Option<String>) = conn
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?))) .query_row(
"SELECT amount_paise, erpnext_payment_entry FROM payments",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap(); .unwrap();
assert_eq!((paid, entry), (12345, None)); assert_eq!((paid, entry), (12345, None));
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap(); let number: String = conn
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
.unwrap();
assert_eq!(number, "INV/2026-001"); assert_eq!(number, "INV/2026-001");
// The config row exists with defaults, and a sync row can reference the invoice. // The config row exists with defaults, and a sync row can reference the invoice.
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap(); let enabled: i64 = conn
.query_row(
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(enabled, 1); assert_eq!(enabled, 1);
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap(); conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err()); assert!(conn
.execute(
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
[]
)
.is_err());
} }
#[test] #[test]
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() { fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
let mut conn = Connection::open_in_memory().unwrap(); let mut conn = Connection::open_in_memory().unwrap();
let v10 = vec![ let v10 = vec![
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10), M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
M::up(M10),
]; ];
Migrations::new(v10).to_latest(&mut conn).unwrap(); Migrations::new(v10).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 10); assert_eq!(user_version(&conn), 10);
@@ -947,7 +1044,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
[], [],
) )
.unwrap(); .unwrap();
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err()); assert!(conn
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
.is_err());
migrations().to_latest(&mut conn).unwrap(); migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11); assert_eq!(user_version(&conn), 11);
@@ -960,12 +1059,27 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
row, row,
("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into())) (
"INV/2026-001".into(),
1,
"synced".into(),
"abc".into(),
String::new(),
Some("2026-04-02T00:00:00Z".into())
)
); );
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap(); conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err()); .unwrap();
assert!(conn
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
.is_err());
// The unique key still guards one row per invoice. // The unique key still guards one row per invoice.
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err()); assert!(conn
.execute(
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
[]
)
.is_err());
} }
fn build_v1_database(path: &Path) { fn build_v1_database(path: &Path) {
@@ -1018,7 +1132,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
let files = backups_in(&backups); let files = backups_in(&backups);
assert_eq!(files.len(), 1); assert_eq!(files.len(), 1);
let name = files[0].file_name().unwrap().to_str().unwrap(); let name = files[0].file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}"); assert!(
name.starts_with("voiced-pre-v0-") && name.ends_with(".db"),
"{name}"
);
let backup = Connection::open(&files[0]).unwrap(); let backup = Connection::open(&files[0]).unwrap();
assert_eq!(user_version(&backup), 0); assert_eq!(user_version(&backup), 0);
let n: i64 = backup let n: i64 = backup
@@ -1039,7 +1156,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(vendor, "Acme Studio"); assert_eq!(vendor, "Acme Studio");
// Invoice backfill. // Invoice backfill.
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn let (origin, snapshot, doc_type, number, total): (
String,
Option<String>,
String,
String,
f64,
) = conn
.query_row( .query_row(
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices", "SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
[], [],
@@ -1107,7 +1230,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.collect(); .collect();
names.sort(); names.sort();
assert_eq!(names.len(), MAX_BACKUPS); assert_eq!(names.len(), MAX_BACKUPS);
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104"))); assert!(names
.iter()
.all(|n| !n.contains("20260101") && !n.contains("20260104")));
assert!(dir.path().join("unrelated.db").exists()); assert!(dir.path().join("unrelated.db").exists());
} }
@@ -1116,8 +1241,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let data = dir.path(); let data = dir.path();
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap(); let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned(); let abs_logo = data
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned(); .join("assets")
.join("logo-1.png")
.to_string_lossy()
.into_owned();
let abs_sig = data
.join("assets")
.join("signature-2.jpg")
.to_string_lossy()
.into_owned();
let foreign = "/somewhere/else/logo.png"; let foreign = "/somewhere/else/logo.png";
conn.execute( conn.execute(
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2", "UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
@@ -1146,9 +1279,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
for _ in 0..2 { for _ in 0..2 {
relativize_asset_paths(&conn, data).unwrap(); relativize_asset_paths(&conn, data).unwrap();
let (logo, sig): (String, String) = conn let (logo, sig): (String, String) = conn
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| { .query_row(
Ok((r.get(0)?, r.get(1)?)) "SELECT logo_path, signature_path FROM app_settings",
}) [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap(); .unwrap();
assert_eq!(logo, "assets/logo-1.png"); assert_eq!(logo, "assets/logo-1.png");
assert_eq!(sig, foreign); assert_eq!(sig, foreign);
@@ -1165,7 +1300,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(json["signaturePath"], "assets/signature-2.jpg"); assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
assert_eq!(json["vendorName"], "Acme"); assert_eq!(json["vendorName"], "Acme");
let bad: String = conn let bad: String = conn
.query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0)) .query_row(
"SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(bad, "not json"); assert_eq!(bad, "not json");
} }
+55 -12
View File
@@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str
if registration == "unregistered" { if registration == "unregistered" {
return TaxType::None; return TaxType::None;
} }
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state }; let pos = if pos_state.trim().is_empty() {
supplier_state
} else {
pos_state
};
if pos == supplier_state { if pos == supplier_state {
TaxType::CgstSgst TaxType::CgstSgst
} else { } else {
@@ -225,8 +229,26 @@ pub fn compute_totals(
} }
const ONES: [&str; 20] = [ const ONES: [&str; 20] = [
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "",
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen", "One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
"Seventeen",
"Eighteen",
"Nineteen",
]; ];
const TENS: [&str; 10] = [ const TENS: [&str; 10] = [
@@ -353,9 +375,18 @@ mod tests {
#[test] #[test]
fn tax_type_serde_strings() { fn tax_type_serde_strings() {
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\""); assert_eq!(
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst); serde_json::to_string(&TaxType::CgstSgst).unwrap(),
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None); "\"cgst_sgst\""
);
assert_eq!(
serde_json::from_str::<TaxType>("\"igst\"").unwrap(),
TaxType::Igst
);
assert_eq!(
serde_json::from_str::<TaxType>("\"none\"").unwrap(),
TaxType::None
);
} }
#[test] #[test]
@@ -384,17 +415,29 @@ mod tests {
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(())); assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
// The algorithm gives W for this body, so a trailing 5 is a typo. // The algorithm gives W for this body, so a trailing 5 is a typo.
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(())); assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit")); assert!(validate_gstin("29ABCDE1234F1Z5")
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit")); .unwrap_err()
.contains("check digit"));
assert!(validate_gstin("27AAPFU0939F1ZX")
.unwrap_err()
.contains("check digit"));
} }
#[test] #[test]
fn gstin_rejects_bad_shapes() { fn gstin_rejects_bad_shapes() {
assert!(validate_gstin("").unwrap_err().contains("15 characters")); assert!(validate_gstin("").unwrap_err().contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters")); assert!(validate_gstin("27AAPFU0939F1Z")
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters")); .unwrap_err()
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format")); .contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format")); assert!(validate_gstin("27AAPFU0939F1ZVV")
.unwrap_err()
.contains("15 characters"));
assert!(validate_gstin("2XAAPFU0939F1ZV")
.unwrap_err()
.contains("format"));
assert!(validate_gstin("27AAPFU0939F1AV")
.unwrap_err()
.contains("format"));
// Valid shape and checksum but state 99 does not exist. // Valid shape and checksum but state 99 does not exist.
let body = b"99AAPFU0939F1Z"; let body = b"99AAPFU0939F1Z";
let mut g = String::from_utf8(body.to_vec()).unwrap(); let mut g = String::from_utf8(body.to_vec()).unwrap();
+224 -52
View File
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
/// Plain http is only for a local development site; anything else must use https. /// Plain http is only for a local development site; anything else must use https.
pub fn is_local_dev_host(host: &str) -> bool { pub fn is_local_dev_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase(); let host = host.trim_end_matches('.').to_ascii_lowercase();
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test") host == "localhost"
|| host == "127.0.0.1"
|| host.ends_with(".localhost")
|| host.ends_with(".test")
} }
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash. /// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
raw.to_string() raw.to_string()
} else { } else {
let host = raw.split(['/', ':']).next().unwrap_or(""); let host = raw.split(['/', ':']).next().unwrap_or("");
let scheme = if is_local_dev_host(host) { "http" } else { "https" }; let scheme = if is_local_dev_host(host) {
"http"
} else {
"https"
};
format!("{scheme}://{raw}") format!("{scheme}://{raw}")
}; };
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?; let mut url = Url::parse(&with_scheme)
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
let host = url let host = url
.host_str() .host_str()
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))? .ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
)); ));
} }
} }
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))), other => {
return Err(ErpError::config(format!(
"Unsupported address scheme \"{other}\"; use https://."
)))
}
} }
if !url.username().is_empty() || url.password().is_some() { if !url.username().is_empty() || url.password().is_some() {
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields.")); return Err(ErpError::config(
"Do not put credentials in the address; use the API key and secret fields.",
));
} }
url.set_query(None); url.set_query(None);
url.set_fragment(None); url.set_fragment(None);
@@ -114,7 +128,9 @@ pub struct ErpClient {
impl std::fmt::Debug for ErpClient { impl std::fmt::Debug for ErpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive() f.debug_struct("ErpClient")
.field("base", &self.base.as_str())
.finish_non_exhaustive()
} }
} }
@@ -127,12 +143,18 @@ impl ErpClient {
opts: ClientOptions, opts: ClientOptions,
) -> Result<Self, ErpError> { ) -> Result<Self, ErpError> {
ensure_crypto_provider(); ensure_crypto_provider();
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?; let base = Url::parse(&normalize_base_url(base_url)?)
.map_err(|e| ErpError::config(e.to_string()))?;
if api_key.trim().is_empty() || !api_secret.is_set() { if api_key.trim().is_empty() || !api_secret.is_set() {
return Err(ErpError::config("Enter the API key and API secret.")); return Err(ErpError::config("Enter the API key and API secret."));
} }
let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose())) let mut auth =
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?; HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
.map_err(|_| {
ErpError::config(
"The API key or secret contains characters that cannot be sent.",
)
})?;
auth.set_sensitive(true); auth.set_sensitive(true);
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -152,13 +174,18 @@ impl ErpClient {
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes()) let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?; .map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
if certs.is_empty() { if certs.is_empty() {
return Err(ErpError::config("The extra CA certificate field holds no certificate.")); return Err(ErpError::config(
"The extra CA certificate field holds no certificate.",
));
} }
builder = builder.tls_certs_merge(certs); builder = builder.tls_certs_merge(certs);
} }
let http = builder let http = builder.build().map_err(|e| {
.build() ErpError::config(format!(
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?; "Could not set up the HTTP client: {}",
error_chain(&e)
))
})?;
Ok(ErpClient { Ok(ErpClient {
http, http,
base, base,
@@ -168,7 +195,13 @@ impl ErpClient {
} }
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> { pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default()) Self::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
&cfg.extra_ca_pem,
ClientOptions::default(),
)
} }
pub fn base_url(&self) -> &str { pub fn base_url(&self) -> &str {
@@ -184,22 +217,38 @@ impl ErpClient {
} }
/// GET, retried on 429, 5xx and timeouts. /// GET, retried on 429, 5xx and timeouts.
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> { pub async fn get(
self.send(Method::GET, segments, query, Payload::None, true).await &self,
segments: &[&str],
query: &[(&str, String)],
) -> Result<Value, ErpError> {
self.send(Method::GET, segments, query, Payload::None, true)
.await
} }
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and /// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
/// timeouts are retried only when `idempotent` is true, because the server may have processed the /// timeouts are retried only when `idempotent` is true, because the server may have processed the
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not. /// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> { pub async fn post(
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await &self,
segments: &[&str],
body: &Value,
idempotent: bool,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
.await
} }
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file /// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or /// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
/// timeout, because an upload that may have landed would attach the file twice. /// timeout, because an upload that may have landed would attach the file twice.
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> { pub async fn post_file(
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await &self,
segments: &[&str],
upload: &Upload<'_>,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
.await
} }
async fn send( async fn send(
@@ -260,7 +309,8 @@ impl ErpClient {
if status.is_success() { if status.is_success() {
return self.parse_success(&text); return self.parse_success(&text);
} }
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous); let retryable =
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
if retryable && can_retry { if retryable && can_retry {
self.backoff(attempt, retry_after).await; self.backoff(attempt, retry_after).await;
attempt += 1; attempt += 1;
@@ -287,7 +337,10 @@ impl ErpClient {
} }
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) { async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10)); let exp = self
.opts
.backoff_base
.saturating_mul(1u32 << attempt.min(10));
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff); let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
} }
@@ -311,7 +364,10 @@ impl ErpClient {
fn map_transport(&self, e: &reqwest::Error) -> ErpError { fn map_transport(&self, e: &reqwest::Error) -> ErpError {
let chain = error_chain(e); let chain = error_chain(e);
let err = if e.is_timeout() { let err = if e.is_timeout() {
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.") ErpError::new(
ErrorKind::Timeout,
"The ERPNext server did not answer in time.",
)
} else if e.is_connect() { } else if e.is_connect() {
let lower = chain.to_ascii_lowercase(); let lower = chain.to_ascii_lowercase();
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") { let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
@@ -319,9 +375,15 @@ impl ErpClient {
} else { } else {
"" ""
}; };
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}")) ErpError::new(
ErrorKind::Network,
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
)
} else { } else {
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain}).")) ErpError::new(
ErrorKind::Network,
format!("The request to ERPNext failed ({chain})."),
)
}; };
self.scrub(err) self.scrub(err)
} }
@@ -352,9 +414,18 @@ mod tests {
#[test] #[test]
fn https_is_accepted_and_normalised() { fn https_is_accepted_and_normalised() {
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com"); assert_eq!(
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com"); normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub"); "https://erp.example.com"
);
assert_eq!(
normalize_base_url("erp.example.com").unwrap(),
"https://erp.example.com"
);
assert_eq!(
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
"https://erp.example.com:8443/sub"
);
} }
#[test] #[test]
@@ -370,14 +441,26 @@ mod tests {
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}")); let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
assert!(url.starts_with("http://"), "{ok} -> {url}"); assert!(url.starts_with("http://"), "{ok} -> {url}");
} }
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] { for bad in [
"http://erp.example.com",
"http://192.168.1.10:8000",
"http://localhost.evil.com",
"http://notlocalhost",
] {
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused"); assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
} }
} }
#[test] #[test]
fn other_schemes_credentials_and_blanks_are_refused() { fn other_schemes_credentials_and_blanks_are_refused() {
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] { for bad in [
"ftp://erp.example.com",
"file:///etc/passwd",
"https://user:pw@erp.example.com",
"",
" ",
"https://",
] {
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused"); assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
} }
} }
@@ -391,8 +474,14 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(value["message"], "Sample@example.com"); assert_eq!(value["message"], "Sample@example.com");
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase(); let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}"); assert!(
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}"); raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
"{raw}"
);
assert!(
raw.contains("authorization: token thekey:thesecret"),
"{raw}"
);
assert!(raw.contains("accept: application/json"), "{raw}"); assert!(raw.contains("accept: application/json"), "{raw}");
} }
@@ -400,11 +489,19 @@ mod tests {
async fn path_segments_are_percent_encoded_once() { async fn path_segments_are_percent_encoded_once() {
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await; let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
client(&mock) client(&mock)
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())]) .get(
&["api", "resource", "Sales Invoice", "INV/2026-001"],
&[("limit_page_length", "0".into())],
)
.await .await
.unwrap(); .unwrap();
let raw = mock.requests.lock().unwrap()[0].clone(); let raw = mock.requests.lock().unwrap()[0].clone();
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}"); assert!(
raw.starts_with(
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
),
"{raw}"
);
} }
#[tokio::test] #[tokio::test]
@@ -412,15 +509,25 @@ mod tests {
let mut limited = reply(429, json!({ "message": "slow down" })); let mut limited = reply(429, json!({ "message": "slow down" }));
limited.headers.push(("Retry-After", "0".into())); limited.headers.push(("Retry-After", "0".into()));
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await; let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap(); let value = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap();
assert_eq!(value["message"], "ok"); assert_eq!(value["message"], "ok");
assert_eq!(mock.hits.load(Ordering::SeqCst), 2); assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
} }
#[tokio::test] #[tokio::test]
async fn persistent_500_exhausts_the_retries() { async fn persistent_500_exhausts_the_retries() {
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await; let mock = serve(vec![reply(
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); 500,
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
)])
.await;
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Server); assert_eq!(err.kind, ErrorKind::Server);
assert_eq!(err.message, "boom"); assert_eq!(err.message, "boom");
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries"); assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
@@ -428,9 +535,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn validation_errors_are_not_retried() { async fn validation_errors_are_not_retried() {
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await; let mock = serve(vec![reply(
417,
json!({ "_error_message": "Rate is required" }),
)])
.await;
let err = client(&mock) let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true) .post(
&["api", "resource", "Sales Invoice"],
&json!({ "a": 1 }),
true,
)
.await .await
.unwrap_err(); .unwrap_err();
assert_eq!(err.kind, ErrorKind::Validation); assert_eq!(err.kind, ErrorKind::Validation);
@@ -440,25 +555,42 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() { async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) }; let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await; let mock = serve(vec![slow]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout); assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 4); assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) }; let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await; let mock = serve(vec![slow]).await;
let err = client(&mock) let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false) .post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await .await
.unwrap_err(); .unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout); assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated"); assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"a write that may have landed is not repeated"
);
} }
#[tokio::test] #[tokio::test]
async fn non_idempotent_write_retries_429_but_not_500() { async fn non_idempotent_write_retries_429_but_not_500() {
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await; let mock = serve(vec![
reply(429, json!({})),
reply(200, json!({ "data": { "name": "X" } })),
])
.await;
let value = client(&mock) let value = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false) .post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await .await
@@ -478,11 +610,20 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn redirects_are_reported_not_followed() { async fn redirects_are_reported_not_followed() {
let mut moved = reply(301, json!({})); let mut moved = reply(301, json!({}));
moved.headers.push(("Location", "https://erp.example.com/".into())); moved
.headers
.push(("Location", "https://erp.example.com/".into()));
let mock = serve(vec![moved]).await; let mock = serve(vec![moved]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol); assert_eq!(err.kind, ErrorKind::Protocol);
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message); assert!(
err.message.contains("https://erp.example.com/"),
"{}",
err.message
);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1); assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
} }
@@ -491,7 +632,10 @@ mod tests {
let mut page = reply(200, json!({})); let mut page = reply(200, json!({}));
page.body = "<html>login</html>".into(); page.body = "<html>login</html>".into();
let mock = serve(vec![page]).await; let mock = serve(vec![page]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol); assert_eq!(err.kind, ErrorKind::Protocol);
} }
@@ -500,7 +644,14 @@ mod tests {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port(); let port = listener.local_addr().unwrap().port();
drop(listener); drop(listener);
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap(); let c = ErpClient::new(
&format!("http://127.0.0.1:{port}"),
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap();
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err(); let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Network); assert_eq!(err.kind, ErrorKind::Network);
assert!(!err.to_string().contains("thesecret")); assert!(!err.to_string().contains("thesecret"));
@@ -509,9 +660,30 @@ mod tests {
#[test] #[test]
fn credentials_are_required_and_a_bad_ca_is_refused() { fn credentials_are_required_and_a_bad_ca_is_refused() {
assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err()); assert!(ErpClient::new(
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err()); "https://erp.example.com",
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err(); "",
&Secret::new("s"),
"",
fast_opts()
)
.is_err());
assert!(ErpClient::new(
"https://erp.example.com",
"k",
&Secret::default(),
"",
fast_opts()
)
.is_err());
let err = ErpClient::new(
"https://erp.example.com",
"k",
&Secret::new("s"),
"not a certificate",
fast_opts(),
)
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Config); assert_eq!(err.kind, ErrorKind::Config);
} }
} }
+57 -17
View File
@@ -29,7 +29,11 @@ impl Secret {
impl std::fmt::Debug for Secret { impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" }) f.write_str(if self.is_set() {
"Secret([redacted])"
} else {
"Secret(unset)"
})
} }
} }
@@ -150,7 +154,9 @@ impl ErpnextConfig {
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) { if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
return u.trim().to_string(); return u.trim().to_string();
} }
default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string()) default_uom_map()
.remove(unit)
.unwrap_or_else(|| "Nos".to_string())
} }
pub fn view(&self) -> ErpnextConfigView { pub fn view(&self) -> ErpnextConfigView {
@@ -182,7 +188,8 @@ impl ErpnextConfig {
attach_pdf: self.attach_pdf, attach_pdf: self.attach_pdf,
auto_push_on_issue: self.auto_push_on_issue, auto_push_on_issue: self.auto_push_on_issue,
create_missing_customers: self.create_missing_customers, create_missing_customers: self.create_missing_customers,
last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null), last_detect_result: serde_json::from_str(&self.last_detect_result)
.unwrap_or(Value::Null),
} }
} }
} }
@@ -296,7 +303,11 @@ fn yes() -> bool {
} }
fn origin_of(url: &str) -> String { fn origin_of(url: &str) -> String {
url.split('/').take(3).collect::<Vec<_>>().join("/").to_ascii_lowercase() url.split('/')
.take(3)
.collect::<Vec<_>>()
.join("/")
.to_ascii_lowercase()
} }
impl ErpnextConfigInput { impl ErpnextConfigInput {
@@ -469,8 +480,11 @@ mod tests {
use super::*; use super::*;
fn input(extra: serde_json::Value) -> ErpnextConfigInput { fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" }); let mut base =
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
base.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap() serde_json::from_value(base).unwrap()
} }
@@ -480,7 +494,10 @@ mod tests {
#[test] #[test]
fn debug_never_prints_the_secret() { fn debug_never_prints_the_secret() {
let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() }; let cfg = ErpnextConfig {
api_secret: Secret::new("s3cr3t-value"),
..Default::default()
};
assert!(!format!("{cfg:?}").contains("s3cr3t-value")); assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value")); assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
} }
@@ -489,7 +506,12 @@ mod tests {
fn defaults_load_from_the_migration_row() { fn defaults_load_from_the_migration_row() {
let cfg = load(&db()).unwrap(); let cfg = load(&db()).unwrap();
assert_eq!(cfg.naming_mode, NamingMode::Mirror); assert_eq!(cfg.naming_mode, NamingMode::Mirror);
assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers); assert!(
!cfg.submit_on_push
&& cfg.attach_pdf
&& !cfg.auto_push_on_issue
&& cfg.create_missing_customers
);
assert_eq!(cfg.uom_for("hour"), "Hour"); assert_eq!(cfg.uom_for("hour"), "Hour");
assert_eq!(cfg.uom_for("session"), "Nos"); assert_eq!(cfg.uom_for("session"), "Nos");
assert!(!cfg.api_secret.is_set()); assert!(!cfg.api_secret.is_set());
@@ -519,34 +541,52 @@ mod tests {
#[test] #[test]
fn blank_secret_keeps_and_explicit_values_replace_or_clear() { fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
let conn = db(); let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap(); let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap(); save(&conn, &first).unwrap();
let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap(); let keep = input(serde_json::json!({ "apiSecret": " " }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(keep.api_secret.expose(), "one"); assert_eq!(keep.api_secret.expose(), "one");
let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap(); let replace = input(serde_json::json!({ "apiSecret": "two" }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(replace.api_secret.expose(), "two"); assert_eq!(replace.api_secret.expose(), "two");
let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap(); let clear = input(serde_json::json!({ "clearSecret": true }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert!(!clear.api_secret.is_set()); assert!(!clear.api_secret.is_set());
} }
#[test] #[test]
fn stored_secret_is_not_reused_for_another_server() { fn stored_secret_is_not_reused_for_another_server() {
let conn = db(); let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap(); let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap(); save(&conn, &first).unwrap();
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap()); let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
.resolve(&load(&conn).unwrap());
assert!(moved.unwrap_err().contains("API secret")); assert!(moved.unwrap_err().contains("API secret"));
// The same server with a different path or case is still the same origin. // The same server with a different path or case is still the same origin.
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap()); let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
.resolve(&load(&conn).unwrap());
assert!(same.is_ok()); assert!(same.is_ok());
} }
#[test] #[test]
fn invalid_address_or_naming_mode_is_rejected() { fn invalid_address_or_naming_mode_is_rejected() {
let stored = ErpnextConfig::default(); let stored = ErpnextConfig::default();
assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err()); assert!(
assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err()); input(serde_json::json!({ "baseUrl": "http://erp.example.com" }))
.resolve(&stored)
.is_err()
);
assert!(input(serde_json::json!({ "namingMode": "random" }))
.resolve(&stored)
.is_err());
} }
#[test] #[test]
+454 -91
View File
@@ -20,12 +20,19 @@ impl Version {
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored. /// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
pub fn parse(s: &str) -> Option<Version> { pub fn parse(s: &str) -> Option<Version> {
let s = s.trim().trim_start_matches(['v', 'V']); let s = s.trim().trim_start_matches(['v', 'V']);
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); let numeric: String = s
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let mut parts = numeric.split('.').filter(|p| !p.is_empty()); let mut parts = numeric.split('.').filter(|p| !p.is_empty());
let major = parts.next()?.parse().ok()?; let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some(Version { major, minor, patch }) Some(Version {
major,
minor,
patch,
})
} }
} }
@@ -59,7 +66,10 @@ pub fn parse_versions(value: &Value) -> ServerVersions {
let apps = value.get("message").unwrap_or(value); let apps = value.get("message").unwrap_or(value);
let version_of = |app: &str| -> Option<String> { let version_of = |app: &str| -> Option<String> {
let entry = apps.get(app)?; let entry = apps.get(app)?;
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?; let v = entry
.get("version")
.and_then(Value::as_str)
.or_else(|| entry.as_str())?;
let v = v.trim(); let v = v.trim();
(!v.is_empty()).then(|| v.to_string()) (!v.is_empty()).then(|| v.to_string())
}; };
@@ -77,7 +87,9 @@ pub const IC_MAX_NUMBER_LEN: usize = 16;
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`. /// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
pub fn ic_number_ok(number: &str) -> bool { pub fn ic_number_ok(number: &str) -> bool {
let mut chars = number.chars(); let mut chars = number.chars();
let Some(first) = chars.next() else { return false }; let Some(first) = chars.next() else {
return false;
};
first.is_alphanumeric() first.is_alphanumeric()
&& number.chars().count() <= IC_MAX_NUMBER_LEN && number.chars().count() <= IC_MAX_NUMBER_LEN
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
@@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
let label = text(row, label_key); let label = text(row, label_key);
Some(OptionItem { Some(OptionItem {
name: name.to_string(), name: name.to_string(),
label: if label.is_empty() { name.to_string() } else { label.to_string() }, label: if label.is_empty() {
name.to_string()
} else {
label.to_string()
},
detail, detail,
}) })
} }
fn join(parts: &[&str]) -> String { fn join(parts: &[&str]) -> String {
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ") parts
.iter()
.filter(|p| !p.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" · ")
} }
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank. /// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
@@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
.get("docs") .get("docs")
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs"))) .or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
.and_then(Value::as_array); .and_then(Value::as_array);
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() }; let Some(doc) = docs.and_then(|d| d.first()) else {
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() }; return Vec::new();
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else { };
let Some(fields) = doc.get("fields").and_then(Value::as_array) else {
return Vec::new();
};
let Some(field) = fields
.iter()
.find(|f| text(f, "fieldname") == "naming_series")
else {
return Vec::new(); return Vec::new();
}; };
text(field, "options") text(field, "options")
.lines() .lines()
.map(str::trim) .map(str::trim)
.filter(|l| !l.is_empty()) .filter(|l| !l.is_empty())
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) }) .map(|l| OptionItem {
name: l.to_string(),
label: l.to_string(),
detail: format!("e.g. {} digits", naming_series_expanded_len(l)),
})
.collect() .collect()
} }
impl ErpClient { impl ErpClient {
pub async fn get_logged_user(&self) -> Result<String, ErpError> { pub async fn get_logged_user(&self) -> Result<String, ErpError> {
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?; let v = self
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string(); .get(&["api", "method", "frappe.auth.get_logged_user"], &[])
.await?;
let user = v
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if user.is_empty() || user.eq_ignore_ascii_case("guest") { if user.is_empty() || user.eq_ignore_ascii_case("guest") {
return Err(ErpError { return Err(ErpError {
kind: ErrorKind::Auth, kind: ErrorKind::Auth,
@@ -202,7 +241,12 @@ impl ErpClient {
} }
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> { pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?; let v = self
.get(
&["api", "method", "frappe.utils.change_log.get_versions"],
&[],
)
.await?;
Ok(parse_versions(&v)) Ok(parse_versions(&v))
} }
@@ -223,7 +267,10 @@ impl ErpClient {
query.push(("filters", filters.to_string())); query.push(("filters", filters.to_string()));
} }
let v = self.get(&["api", "resource", doctype], &query).await?; let v = self.get(&["api", "resource", doctype], &query).await?;
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default()) Ok(v.get("data")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
} }
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> { pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
@@ -234,50 +281,117 @@ impl ErpClient {
($list:literal, $target:expr, $call:expr) => { ($list:literal, $target:expr, $call:expr) => {
match $call.await { match $call.await {
Ok(rows) => $target = rows, Ok(rows) => $target = rows,
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => { Err(e)
if matches!(
e.kind,
ErrorKind::Auth
| ErrorKind::Network
| ErrorKind::Timeout
| ErrorKind::Config
| ErrorKind::Protocol
) =>
{
return Err(e) return Err(e)
} }
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }), Err(e) => out.errors.push(OptionError {
list: $list.to_string(),
message: e.to_string(),
}),
} }
}; };
} }
load!("companies", out.companies, async { load!("companies", out.companies, async {
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc") self.list_resource(
"Company",
&[
"name",
"company_name",
"abbr",
"country",
"default_currency",
],
Value::Null,
"name asc",
)
.await .await
.map(|rows| { .map(|rows| {
rows.iter() rows.iter()
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")]))) .filter_map(|r| {
item(
r,
"company_name",
join(&[text(r, "abbr"), text(r, "default_currency")]),
)
})
.collect() .collect()
}) })
}); });
load!("itemGroups", out.item_groups, async { load!("itemGroups", out.item_groups, async {
self.list_resource("Item Group", &["name"], Value::Null, "name asc") self.list_resource("Item Group", &["name"], Value::Null, "name asc")
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
}); });
load!("uoms", out.uoms, async { load!("uoms", out.uoms, async {
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc") self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
}); });
load!("priceLists", out.price_lists, async { load!("priceLists", out.price_lists, async {
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc") self.list_resource(
"Price List",
&["name"],
json!([["enabled", "=", 1], ["selling", "=", 1]]),
"name asc",
)
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
}); });
load!("customerGroups", out.customer_groups, async { load!("customerGroups", out.customer_groups, async {
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc") self.list_resource(
"Customer Group",
&["name"],
json!([["is_group", "=", 0]]),
"name asc",
)
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
}); });
load!("territories", out.territories, async { load!("territories", out.territories, async {
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc") self.list_resource(
"Territory",
&["name"],
json!([["is_group", "=", 0]]),
"name asc",
)
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
}); });
load!("namingSeries", out.naming_series, async { load!("namingSeries", out.naming_series, async {
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())]) self.get(
&["api", "method", "frappe.desk.form.load.getdoctype"],
&[("doctype", "Sales Invoice".to_string())],
)
.await .await
.map(|v| parse_naming_series(&v)) .map(|v| parse_naming_series(&v))
}); });
@@ -291,11 +405,32 @@ impl ErpClient {
["Dynamic Link", "link_name", "=", company], ["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0] ["disabled", "=", 0]
]); ]);
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"]; let base = [
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"]; "name",
"address_title",
"address_line1",
"city",
"state",
"pincode",
];
let with_gstin = [
"name",
"address_title",
"address_line1",
"city",
"state",
"pincode",
"gstin",
];
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted"). // `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await { let rows = match self
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?, .list_resource("Address", &with_gstin, filters.clone(), "name asc")
.await
{
Err(e) if e.kind == ErrorKind::Validation => {
self.list_resource("Address", &base, filters, "name asc")
.await?
}
other => other?, other => other?,
}; };
Ok::<_, ErpError>( Ok::<_, ErpError>(
@@ -304,7 +439,13 @@ impl ErpClient {
item( item(
r, r,
"address_title", "address_title",
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]), join(&[
text(r, "address_line1"),
text(r, "city"),
text(r, "state"),
text(r, "pincode"),
text(r, "gstin"),
]),
) )
}) })
.collect(), .collect(),
@@ -314,21 +455,39 @@ impl ErpClient {
self.list_resource( self.list_resource(
"Account", "Account",
&["name", "account_name", "account_type"], &["name", "account_name", "account_type"],
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]), json!([
["company", "=", company],
["root_type", "=", "Income"],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc", "name asc",
) )
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
.collect()
})
}); });
load!("taxAccounts", out.tax_accounts, async { load!("taxAccounts", out.tax_accounts, async {
self.list_resource( self.list_resource(
"Account", "Account",
&["name", "account_name", "account_type"], &["name", "account_name", "account_type"],
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]), json!([
["company", "=", company],
["account_type", "=", "Tax"],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc", "name asc",
) )
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
.collect()
})
}); });
load!("taxTemplates", out.tax_templates, async { load!("taxTemplates", out.tax_templates, async {
self.list_resource( self.list_resource(
@@ -338,17 +497,29 @@ impl ErpClient {
"name asc", "name asc",
) )
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "title", String::new()))
.collect()
})
}); });
load!("costCenters", out.cost_centers, async { load!("costCenters", out.cost_centers, async {
self.list_resource( self.list_resource(
"Cost Center", "Cost Center",
&["name", "cost_center_name"], &["name", "cost_center_name"],
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]), json!([
["company", "=", company],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc", "name asc",
) )
.await .await
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect()) .map(|rows| {
rows.iter()
.filter_map(|r| item(r, "cost_center_name", String::new()))
.collect()
})
}); });
Ok(out) Ok(out)
} }
@@ -363,7 +534,10 @@ pub struct Warning {
} }
fn warn(code: &str, message: impl Into<String>) -> Warning { fn warn(code: &str, message: impl Into<String>) -> Warning {
Warning { code: code.to_string(), message: message.into() } Warning {
code: code.to_string(),
message: message.into(),
}
} }
/// Facts from the local database that the warnings need. /// Facts from the local database that the warnings need.
@@ -390,7 +564,9 @@ pub struct RemoteFacts {
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> { pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
let for_company: Vec<&Value> = rows let for_company: Vec<&Value> = rows
.iter() .iter()
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")) .filter(|r| {
text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")
})
.collect(); .collect();
if for_company.is_empty() { if for_company.is_empty() {
return vec![warn( return vec![warn(
@@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
} }
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test. /// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> { pub fn build_warnings(
cfg: &ErpnextConfig,
local: &LocalFacts,
remote: &RemoteFacts,
) -> Vec<Warning> {
let mut w = Vec::new(); let mut w = Vec::new();
let features = remote.versions.as_ref().map(feature_switches); let features = remote.versions.as_ref().map(feature_switches);
let ic = features.is_some_and(|f| f.india_compliance); let ic = features.is_some_and(|f| f.india_compliance);
@@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
} }
} }
} else if cfg.naming_series.is_empty() { } else if cfg.naming_series.is_empty() {
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen.")); w.push(warn(
"naming_series_missing",
"ERPNext series naming is selected but no naming series is chosen.",
));
} }
if local.vendor_registered { if local.vendor_registered {
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() { if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty()
{
w.push(warn( w.push(warn(
"tax_accounts_missing", "tax_accounts_missing",
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.", "The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
@@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
} }
} }
if cfg.company_address.is_empty() { if cfg.company_address.is_empty() {
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected.")); w.push(warn(
"company_address_missing",
"India Compliance needs a company address on every invoice; none is selected.",
));
} else if remote.company_address_count == Some(0) { } else if remote.company_address_count == Some(0) {
w.push(warn("company_address_missing", "No address is linked to the company on the site.")); w.push(warn(
"company_address_missing",
"No address is linked to the company on the site.",
));
} }
match cfg.naming_mode { match cfg.naming_mode {
NamingMode::Mirror => { NamingMode::Mirror => {
@@ -533,14 +723,21 @@ pub struct ConnectionTest {
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only /// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
/// produce warnings. /// produce warnings.
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> { pub async fn test_connection(
client: &ErpClient,
cfg: &ErpnextConfig,
local: &LocalFacts,
) -> Result<ConnectionTest, ErpError> {
let user = client.get_logged_user().await?; let user = client.get_logged_user().await?;
let versions = match client.get_versions().await { let versions = match client.get_versions().await {
Ok(v) => Some(v), Ok(v) => Some(v),
Err(e) if e.kind == ErrorKind::Auth => return Err(e), Err(e) if e.kind == ErrorKind::Auth => return Err(e),
Err(_) => None, Err(_) => None,
}; };
let mut remote = RemoteFacts { versions, ..Default::default() }; let mut remote = RemoteFacts {
versions,
..Default::default()
};
let features = remote.versions.as_ref().map(feature_switches); let features = remote.versions.as_ref().map(feature_switches);
if features.is_some_and(|f| f.india_compliance) { if features.is_some_and(|f| f.india_compliance) {
if !cfg.company.is_empty() { if !cfg.company.is_empty() {
@@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
.list_resource( .list_resource(
"Address", "Address",
&["name"], &["name"],
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]), json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", cfg.company],
["disabled", "=", 0]
]),
"name asc", "name asc",
) )
.await .await
@@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
client client
.get(&["api", "resource", "GST Settings", "GST Settings"], &[]) .get(&["api", "resource", "GST Settings", "GST Settings"], &[])
.await .await
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default()) .map(|v| {
v.get("data")
.and_then(|d| d.get("gst_accounts"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
})
.map_err(|e| e.to_string()), .map_err(|e| e.to_string()),
); );
} }
@@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
let warnings = build_warnings(cfg, local, &remote); let warnings = build_warnings(cfg, local, &remote);
let versions = remote.versions.unwrap_or_default(); let versions = remote.versions.unwrap_or_default();
let features = feature_switches(&versions); let features = feature_switches(&versions);
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings }) Ok(ConnectionTest {
user,
india_compliance: features.india_compliance,
features,
versions,
warnings,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -608,10 +821,38 @@ mod tests {
#[test] #[test]
fn versions_parse_leniently() { fn versions_parse_leniently() {
assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 })); assert_eq!(
assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 })); Version::parse("15.73.2"),
assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 })); Some(Version {
assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 })); major: 15,
minor: 73,
patch: 2
})
);
assert_eq!(
Version::parse("v16.0.0-dev"),
Some(Version {
major: 16,
minor: 0,
patch: 0
})
);
assert_eq!(
Version::parse("15.1"),
Some(Version {
major: 15,
minor: 1,
patch: 0
})
);
assert_eq!(
Version::parse("15.73.0-beta.2"),
Some(Version {
major: 15,
minor: 73,
patch: 0
})
);
assert_eq!(Version::parse("unknown"), None); assert_eq!(Version::parse("unknown"), None);
} }
@@ -640,7 +881,11 @@ mod tests {
assert_eq!(v.frappe.as_deref(), Some("15.73.0")); assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
assert_eq!(v.erpnext.as_deref(), Some("15.121.6")); assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0")); assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none()); assert!(
parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } }))
.india_compliance
.is_none()
);
} }
#[test] #[test]
@@ -648,7 +893,16 @@ mod tests {
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] { for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
assert!(ic_number_ok(ok), "{ok}"); assert!(ic_number_ok(ok), "{ok}");
} }
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] { for bad in [
"",
"12345678901234567",
"_AB",
"AB 01",
"AB_01",
"-AB",
"/AB",
"AB#1",
] {
assert!(!ic_number_ok(bad), "{bad}"); assert!(!ic_number_ok(bad), "{bad}");
} }
} }
@@ -656,9 +910,18 @@ mod tests {
#[test] #[test]
fn naming_series_is_expanded_before_measuring() { fn naming_series_is_expanded_before_measuring() {
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5); assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5); assert_eq!(
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4); naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"),
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5); 9 + 4 + 1 + 5
);
assert_eq!(
naming_series_expanded_len("INV-.MM.-.DD.-.####"),
4 + 2 + 1 + 2 + 1 + 4
);
assert_eq!(
naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"),
15 + 4 + 1 + 5
);
} }
#[test] #[test]
@@ -668,33 +931,54 @@ mod tests {
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" } { "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
]}]}); ]}]});
let items = parse_naming_series(&resp); let items = parse_naming_series(&resp);
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]); assert_eq!(
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
["SINV-.YY.-", "ACC-SINV-.YYYY.-"]
);
assert!(parse_naming_series(&json!({})).is_empty()); assert!(parse_naming_series(&json!({})).is_empty());
} }
#[test] #[test]
fn a_healthy_ic_setup_has_no_warnings() { fn a_healthy_ic_setup_has_no_warnings() {
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) }; let local = LocalFacts {
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]); vendor_registered: true,
invalid_numbers: vec![],
next_number: Some("INV/2026-001".into()),
};
assert_eq!(
build_warnings(&cfg(), &local, &ic_versions("15.73.0")),
vec![]
);
} }
#[test] #[test]
fn gst_accounts_not_configured_or_mismatched_are_flagged() { fn gst_accounts_not_configured_or_mismatched_are_flagged() {
let local = LocalFacts { vendor_registered: true, ..Default::default() }; let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
let mut remote = ic_versions("15.73.0"); let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Ok(vec![])); remote.gst_accounts = Some(Ok(vec![]));
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]); assert_eq!(
codes(&build_warnings(&cfg(), &local, &remote)),
["gst_accounts_not_configured"]
);
let mut remote = ic_versions("15.73.0"); let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output", remote.gst_accounts = Some(Ok(vec![
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })])); json!({ "company": "Test Co", "account_type": "Output",
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" }),
]));
let w = build_warnings(&cfg(), &local, &remote); let w = build_warnings(&cfg(), &local, &remote);
assert_eq!(codes(&w), ["gst_account_mismatch"]); assert_eq!(codes(&w), ["gst_account_mismatch"]);
assert!(w[0].message.contains("SGST")); assert!(w[0].message.contains("SGST"));
let mut remote = ic_versions("15.73.0"); let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Err("HTTP 403".into())); remote.gst_accounts = Some(Err("HTTP 403".into()));
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]); assert_eq!(
codes(&build_warnings(&cfg(), &local, &remote)),
["gst_settings_unreadable"]
);
} }
#[test] #[test]
@@ -727,20 +1011,45 @@ mod tests {
let mut remote = ic_versions("15.73.0"); let mut remote = ic_versions("15.73.0");
remote.company_address_count = Some(0); remote.company_address_count = Some(0);
assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]); assert_eq!(
codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)),
["company_address_missing"]
);
} }
#[test] #[test]
fn required_settings_and_unknown_versions_are_flagged() { fn required_settings_and_unknown_versions_are_flagged() {
let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default()); let w = build_warnings(
assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]); &ErpnextConfig::default(),
&LocalFacts {
vendor_registered: true,
..Default::default()
},
&RemoteFacts::default(),
);
assert_eq!(
codes(&w),
[
"versions_unknown",
"company_missing",
"income_account_missing",
"tax_accounts_missing"
]
);
} }
#[test] #[test]
fn registered_vendor_without_ic_gets_a_notice() { fn registered_vendor_without_ic_gets_a_notice() {
let mut remote = ic_versions("15.73.0"); let mut remote = ic_versions("15.73.0");
remote.versions.as_mut().unwrap().india_compliance = None; remote.versions.as_mut().unwrap().india_compliance = None;
let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote); let w = build_warnings(
&cfg(),
&LocalFacts {
vendor_registered: true,
..Default::default()
},
&remote,
);
assert_eq!(codes(&w), ["ic_not_detected"]); assert_eq!(codes(&w), ["ic_not_detected"]);
} }
@@ -753,27 +1062,51 @@ mod tests {
if path.contains("get_logged_user") { if path.contains("get_logged_user") {
reply(200, json!({ "message": "integration@example.com" })) reply(200, json!({ "message": "integration@example.com" }))
} else if path.contains("get_versions") { } else if path.contains("get_versions") {
reply(200, json!({ "message": { reply(
200,
json!({ "message": {
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" } "frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
}})) }}),
)
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") { } else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
reply(200, json!({ "data": { "gst_accounts": [] } })) reply(200, json!({ "data": { "gst_accounts": [] } }))
} else if path.starts_with("/api/resource/Address?") { } else if path.starts_with("/api/resource/Address?") {
reply(200, json!({ "data": [ reply(
200,
json!({ "data": [
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" } { "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
]})) ]}),
)
} else if path.starts_with("/api/resource/Company?") { } else if path.starts_with("/api/resource/Company?") {
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] })) reply(
200,
json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }),
)
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") { } else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] })) reply(
200,
json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }),
)
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") { } else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] })) reply(
200,
json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }),
)
} else if path.starts_with("/api/resource/Price%20List?") { } else if path.starts_with("/api/resource/Price%20List?") {
reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" })) reply(
403,
json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }),
)
} else if path.contains("getdoctype") { } else if path.contains("getdoctype") {
reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] })) reply(
200,
json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }),
)
} else if path.starts_with("/api/resource/UOM?") { } else if path.starts_with("/api/resource/UOM?") {
reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] })) reply(
200,
json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }),
)
} else { } else {
reply(200, json!({ "data": [] })) reply(200, json!({ "data": [] }))
} }
@@ -782,8 +1115,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn connection_test_reports_user_versions_ic_and_warnings() { async fn connection_test_reports_user_versions_ic_and_warnings() {
let mock = serve_fn(|_, line| site(line)).await; let mock = serve_fn(|_, line| site(line)).await;
let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() }; let local = LocalFacts {
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap(); vendor_registered: true,
next_number: Some("INV/2026-001".into()),
..Default::default()
};
let result = test_connection(&client(&mock), &cfg(), &local)
.await
.unwrap();
assert_eq!(result.user, "integration@example.com"); assert_eq!(result.user, "integration@example.com");
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0")); assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
assert!(result.india_compliance && result.features.v2_naming); assert!(result.india_compliance && result.features.v2_naming);
@@ -810,17 +1149,25 @@ mod tests {
let options = client(&mock).load_options("Test Co").await.unwrap(); let options = client(&mock).load_options("Test Co").await.unwrap();
assert_eq!(options.company_addresses.len(), 1); assert_eq!(options.company_addresses.len(), 1);
assert_eq!(options.company_addresses[0].name, "Test Co-Billing"); assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors); assert!(
options.errors.iter().all(|e| e.list != "companyAddresses"),
"{:?}",
options.errors
);
} }
#[tokio::test] #[tokio::test]
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() { async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await; let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth); assert_eq!(err.kind, ErrorKind::Auth);
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await; let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth); assert_eq!(err.kind, ErrorKind::Auth);
assert!(!err.to_string().contains("thesecret")); assert!(!err.to_string().contains("thesecret"));
} }
@@ -837,17 +1184,29 @@ mod tests {
assert_eq!(options.income_accounts[0].name, "Sales - AC"); assert_eq!(options.income_accounts[0].name, "Sales - AC");
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC"); assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
assert_eq!(options.naming_series[0].name, "SINV-.YY.-"); assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]); assert_eq!(
options
.uoms
.iter()
.map(|u| u.name.as_str())
.collect::<Vec<_>>(),
["Nos", "Hour"]
);
assert!(options.price_lists.is_empty()); assert!(options.price_lists.is_empty());
assert_eq!(options.errors.len(), 1); assert_eq!(options.errors.len(), 1);
assert_eq!(options.errors[0].list, "priceLists"); assert_eq!(options.errors[0].list, "priceLists");
assert!(options.errors[0].message.contains("No permission for Price List")); assert!(options.errors[0]
.message
.contains("No permission for Price List"));
// Company-scoped lists are skipped until a company is chosen. // Company-scoped lists are skipped until a company is chosen.
let none = client(&mock).load_options(" ").await.unwrap(); let none = client(&mock).load_options(" ").await.unwrap();
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty()); assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
let requests = mock.requests.lock().unwrap().join("\n"); let requests = mock.requests.lock().unwrap().join("\n");
assert!(requests.contains("root_type"), "income accounts are filtered by root type"); assert!(
requests.contains("root_type"),
"income accounts are filtered by root type"
);
assert!(requests.contains("limit_page_length=0")); assert!(requests.contains("limit_page_length=0"));
} }
@@ -856,6 +1215,10 @@ mod tests {
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await; let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
let err = client(&mock).load_options("Test Co").await.unwrap_err(); let err = client(&mock).load_options("Test Co").await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth); assert_eq!(err.kind, ErrorKind::Auth);
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure"); assert_eq!(
mock.hits.load(std::sync::atomic::Ordering::SeqCst),
1,
"stops at the first auth failure"
);
} }
} }
+132 -31
View File
@@ -81,7 +81,12 @@ impl From<ErpError> for String {
impl ErpError { impl ErpError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self { pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
ErpError { kind, status: None, message: message.into(), exc_type: None } ErpError {
kind,
status: None,
message: message.into(),
exc_type: None,
}
} }
pub fn config(message: impl Into<String>) -> Self { pub fn config(message: impl Into<String>) -> Self {
@@ -99,8 +104,15 @@ impl ErpError {
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") { if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
kind = ErrorKind::Duplicate; kind = ErrorKind::Duplicate;
} }
let message = parsed.message.unwrap_or_else(|| default_status_text(status)); let message = parsed
ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type } .message
.unwrap_or_else(|| default_status_text(status));
ErpError {
kind,
status: Some(status),
message,
exc_type: parsed.exc_type,
}
} }
/// Removes every occurrence of the given secrets from the message. /// Removes every occurrence of the given secrets from the message.
@@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600;
pub fn parse_error_body(body: &str) -> ParsedError { pub fn parse_error_body(body: &str) -> ParsedError {
let Ok(json) = serde_json::from_str::<Value>(body) else { let Ok(json) = serde_json::from_str::<Value>(body) else {
let text = strip_html(body); let text = strip_html(body);
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); let first = text
return ParsedError { message: non_empty(truncate(first)), exc_type: None }; .lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
return ParsedError {
message: non_empty(truncate(first)),
exc_type: None,
};
}; };
let Some(obj) = json.as_object() else { let Some(obj) = json.as_object() else {
return ParsedError::default(); return ParsedError::default();
}; };
let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string); let mut exc_type = obj
.get("exc_type")
.and_then(Value::as_str)
.map(str::to_string);
let mut messages: Vec<String> = Vec::new(); let mut messages: Vec<String> = Vec::new();
if let Some(errors) = obj.get("errors").and_then(Value::as_array) { if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
@@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError {
match e { match e {
Value::String(s) => push_unique(&mut messages, strip_html(s)), Value::String(s) => push_unique(&mut messages, strip_html(s)),
Value::Object(o) => { Value::Object(o) => {
let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()); let text = o
let exception = o.get("exception").and_then(Value::as_str).map(split_exception); .get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty());
let exception = o
.get("exception")
.and_then(Value::as_str)
.map(split_exception);
if exc_type.is_none() { if exc_type.is_none() {
exc_type = o exc_type = o
.get("type") .get("type")
@@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError {
.map(str::to_string) .map(str::to_string)
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone())); .or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
} }
if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) { if let Some(m) =
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
{
push_unique(&mut messages, m); push_unique(&mut messages, m);
} }
} }
@@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
} }
} }
if messages.is_empty() { if messages.is_empty() {
if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) { if let Some(m) = obj
.get("_error_message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m); messages.push(m);
} }
} }
@@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
} }
} }
if messages.is_empty() { if messages.is_empty() {
if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) { if let Some(m) = obj
.get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m); messages.push(m);
} }
} }
@@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError {
} }
if exc_type.is_none() { if exc_type.is_none() {
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`. // v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0); exc_type = obj
.get("exception")
.and_then(Value::as_str)
.and_then(|e| split_exception(e).0);
} }
ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type } ParsedError {
message: non_empty(truncate(&messages.join("; "))),
exc_type,
}
} }
fn server_messages(raw: &str) -> Vec<String> { fn server_messages(raw: &str) -> Vec<String> {
@@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec<String> {
.filter_map(|item| { .filter_map(|item| {
let text = match item { let text = match item {
Value::String(s) => match serde_json::from_str::<Value>(s) { Value::String(s) => match serde_json::from_str::<Value>(s) {
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(), Ok(Value::Object(o)) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => s.clone(), _ => s.clone(),
}, },
Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(), Value::Object(o) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => String::new(), _ => String::new(),
}; };
let text = strip_html(&text); let text = strip_html(&text);
@@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec<String> {
fn split_exception(raw: &str) -> (Option<String>, String) { fn split_exception(raw: &str) -> (Option<String>, String) {
let first_line = raw.trim().lines().next().unwrap_or("").trim(); let first_line = raw.trim().lines().next().unwrap_or("").trim();
if let Some((prefix, rest)) = first_line.split_once(": ") { if let Some((prefix, rest)) = first_line.split_once(": ") {
let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception"); let class_like =
if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') { prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
if class_like
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
let ty = prefix.rsplit('.').next().map(str::to_string); let ty = prefix.rsplit('.').next().map(str::to_string);
return (ty, strip_html(rest)); return (ty, strip_html(rest));
} }
} }
if !first_line.is_empty() if !first_line.is_empty()
&& first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') && first_line
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& first_line.contains('.') && first_line.contains('.')
{ {
// `frappe.exceptions.DoesNotExistError` with no message. // `frappe.exceptions.DoesNotExistError` with no message.
return (first_line.rsplit('.').next().map(str::to_string), String::new()); return (
first_line.rsplit('.').next().map(str::to_string),
String::new(),
);
} }
(None, strip_html(first_line)) (None, strip_html(first_line))
} }
@@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String {
} }
'>' if in_tag => { '>' if in_tag => {
in_tag = false; in_tag = false;
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase(); let name = tag
if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") { .trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
name.as_str(),
"br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3"
) {
out.push('\n'); out.push('\n');
} }
} }
@@ -339,7 +412,10 @@ mod tests {
}) })
.to_string(); .to_string();
let parsed = parse_error_body(&body); let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem")); assert_eq!(
parsed.message.as_deref(),
Some("Row 1: Account Sales & more; Second\nproblem")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError")); assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
let err = ErpError::from_response(417, &body); let err = ErpError::from_response(417, &body);
assert_eq!(err.kind, ErrorKind::Validation); assert_eq!(err.kind, ErrorKind::Validation);
@@ -354,7 +430,10 @@ mod tests {
let b = parse_error_body( let b = parse_error_body(
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(), &json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
); );
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists")); assert_eq!(
b.message.as_deref(),
Some("Sales Invoice INV/1 already exists")
);
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError")); assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string()); let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
@@ -363,8 +442,13 @@ mod tests {
#[test] #[test]
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() { fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
let parsed = parse_error_body(&json!({ "exception": "Something: with a colon but no class" }).to_string()); let parsed = parse_error_body(
assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class")); &json!({ "exception": "Something: with a colon but no class" }).to_string(),
);
assert_eq!(
parsed.message.as_deref(),
Some("Something: with a colon but no class")
);
assert_eq!(parsed.exc_type, None); assert_eq!(parsed.exc_type, None);
} }
@@ -378,7 +462,10 @@ mod tests {
}) })
.to_string(); .to_string();
let parsed = parse_error_body(&body); let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing")); assert_eq!(
parsed.message.as_deref(),
Some("Customer X is disabled; Row 2: rate missing")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError")); assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
} }
@@ -406,22 +493,36 @@ mod tests {
(502, ErrorKind::Server), (502, ErrorKind::Server),
(418, ErrorKind::Other), (418, ErrorKind::Other),
] { ] {
assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}"); assert_eq!(
ErpError::from_response(status, "").kind,
kind,
"status {status}"
);
} }
// The exception class wins when the status is generic. // The exception class wins when the status is generic.
let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string()); let dup = ErpError::from_response(
417,
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
);
assert_eq!(dup.kind, ErrorKind::Duplicate); assert_eq!(dup.kind, ErrorKind::Duplicate);
} }
#[test] #[test]
fn secrets_are_redacted_from_messages() { fn secrets_are_redacted_from_messages() {
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]); let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
.redacted(&["topsecret", "abc123"]);
assert_eq!(err.message, "bad token [redacted]:[redacted] in header"); assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
} }
#[test] #[test]
fn display_carries_the_status_and_hint() { fn display_carries_the_status_and_hint() {
let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string()); let err = ErpError::from_response(
assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials"); 401,
&json!({ "message": "Invalid credentials" }).to_string(),
);
assert_eq!(
err.to_string(),
"Authentication failed (HTTP 401): Invalid credentials"
);
} }
} }
+460 -88
View File
@@ -11,8 +11,8 @@
use super::client::{ClientOptions, ErpClient}; use super::client::{ClientOptions, ErpClient};
use super::config::{self, ErpnextConfig, NamingMode, Secret}; use super::config::{self, ErpnextConfig, NamingMode, Secret};
use super::errors::ErrorKind;
use super::discovery::{test_connection, ConnectionTest, LocalFacts}; use super::discovery::{test_connection, ConnectionTest, LocalFacts};
use super::errors::ErrorKind;
use super::push::{push_invoice, push_payment, PushResult}; use super::push::{push_invoice, push_payment, PushResult};
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl}; use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
use crate::commands::payments::{record_payment_impl, PaymentInput}; use crate::commands::payments::{record_payment_impl, PaymentInput};
@@ -78,7 +78,14 @@ impl Live {
..Default::default() ..Default::default()
}; };
edit(&mut cfg); edit(&mut cfg);
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap(); let http = ErpClient::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
"",
ClientOptions::default(),
)
.unwrap();
let mut conn = crate::db::open_in_memory().unwrap(); let mut conn = crate::db::open_in_memory().unwrap();
conn.execute( conn.execute(
@@ -88,8 +95,24 @@ impl Live {
) )
.unwrap(); .unwrap();
for (name, gstin, state, line1, city, pin, category) in [ for (name, gstin, state, line1, city, pin, category) in [
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"), (
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"), "Voiced Live Intra Co",
"",
"27",
"5 FC Road",
"Pune",
"411004",
"unregistered",
),
(
"Voiced Live Inter Co",
KA_GSTIN,
"29",
"12 MG Road",
"Bengaluru",
"560001",
"registered_regular",
),
] { ] {
conn.execute( conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at) "INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
@@ -98,12 +121,26 @@ impl Live {
) )
.unwrap(); .unwrap();
} }
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64; let nanos = std::time::SystemTime::now()
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)); .duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let prefix = format!(
"L{}{}/",
base36(nanos / 1_000_000, 4),
base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)
);
start_new_series_impl(&mut conn, &prefix, 3).unwrap(); start_new_series_impl(&mut conn, &prefix, 3).unwrap();
config::save(&conn, &cfg).unwrap(); config::save(&conn, &cfg).unwrap();
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix }; let live = Live {
db: Mutex::new(conn),
dir: tempfile::tempdir().unwrap(),
http,
abbr,
ic,
prefix,
};
// The same step the UI performs before the first push. // The same step the UI performs before the first push.
let ct = live.connection_test(&cfg).await; let ct = live.connection_test(&cfg).await;
let mut cfg = cfg; let mut cfg = cfg;
@@ -113,8 +150,13 @@ impl Live {
} }
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest { async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
let local = LocalFacts { vendor_registered: true, ..Default::default() }; let local = LocalFacts {
test_connection(&self.http, cfg, &local).await.expect("connection test") vendor_registered: true,
..Default::default()
};
test_connection(&self.http, cfg, &local)
.await
.expect("connection test")
} }
fn cfg(&self) -> ErpnextConfig { fn cfg(&self) -> ErpnextConfig {
@@ -176,7 +218,10 @@ impl Live {
self.db self.db
.lock() .lock()
.unwrap() .unwrap()
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id]) .execute(
"UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2",
params![sha, inv.id],
)
.unwrap(); .unwrap();
} }
@@ -185,13 +230,22 @@ impl Live {
} }
async fn doc(&self, doctype: &str, name: &str) -> Value { async fn doc(&self, doctype: &str, name: &str) -> Value {
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}")); let v = self
.http
.get(&["api", "resource", doctype, name], &[])
.await
.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
v["data"].clone() v["data"].clone()
} }
async fn invoices_named(&self, number: &str) -> Vec<Value> { async fn invoices_named(&self, number: &str) -> Vec<Value> {
self.http self.http
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc") .list_resource(
"Sales Invoice",
&["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("%{number}%")]]),
"creation asc",
)
.await .await
.unwrap() .unwrap()
} }
@@ -201,7 +255,10 @@ impl Live {
.list_resource( .list_resource(
"File", "File",
&["name", "file_name", "is_private", "file_url"], &["name", "file_name", "is_private", "file_url"],
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]), json!([
["attached_to_doctype", "=", doctype],
["attached_to_name", "=", name]
]),
"creation asc", "creation asc",
) )
.await .await
@@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec<u8> {
for o in offsets { for o in offsets {
out.extend(format!("{o:010} 00000 n \n").into_bytes()); out.extend(format!("{o:010} 00000 n \n").into_bytes());
} }
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); out.extend(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.into_bytes(),
);
out out
} }
@@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) {
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed. /// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) { async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number); assert_eq!(
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled"); paise(&doc["grand_total"]),
crate::gst::rupees_to_paise(inv.total),
"grand_total of {}",
inv.number
);
assert_eq!(
doc["rounded_total"].as_f64().unwrap_or(0.0),
0.0,
"rounding is disabled"
);
assert_eq!(doc["posting_date"], inv.invoice_date.as_str()); assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
let taxes = doc["taxes"].as_array().unwrap(); let taxes = doc["taxes"].as_array().unwrap();
let by_head = |needle: &str| -> Option<i64> { let by_head = |needle: &str| -> Option<i64> {
@@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle)) .find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
.map(|t| paise(&t["tax_amount"])) .map(|t| paise(&t["tax_amount"]))
}; };
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number); assert_eq!(
by_head("CGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.cgst_amount).max(0),
"CGST of {}",
inv.number
);
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not. // IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number); assert_eq!(
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number); by_head("SGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.sgst_amount),
"SGST of {}",
inv.number
);
assert_eq!(
by_head("IGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.igst_amount),
"IGST of {}",
inv.number
);
let _ = live; let _ = live;
} }
@@ -290,7 +377,10 @@ async fn live_connection_and_options() {
let ct = live.connection_test(&cfg).await; let ct = live.connection_test(&cfg).await;
println!("{}", serde_json::to_string_pretty(&ct).unwrap()); println!("{}", serde_json::to_string_pretty(&ct).unwrap());
assert!(ct.user.contains('@')); assert!(ct.user.contains('@'));
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true)); assert_eq!(
ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")),
Some(true)
);
assert!(ct.versions.erpnext.is_some()); assert!(ct.versions.erpnext.is_some());
assert_eq!(ct.india_compliance, live.ic); assert_eq!(ct.india_compliance, live.ic);
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming"); assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
@@ -298,14 +388,25 @@ async fn live_connection_and_options() {
let o = live.http.load_options(&cfg.company).await.unwrap(); let o = live.http.load_options(&cfg.company).await.unwrap();
println!("option errors: {:?}", o.errors); println!("option errors: {:?}", o.errors);
assert!(o.errors.is_empty(), "{:?}", o.errors); assert!(o.errors.is_empty(), "{:?}", o.errors);
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name); let has =
|list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
assert!(has(&o.companies, &cfg.company)); assert!(has(&o.companies, &cfg.company));
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts); assert!(
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts); has(&o.income_accounts, &cfg.income_account),
"{:?}",
o.income_accounts
);
assert!(
o.tax_accounts.iter().any(|a| a.name.contains("CGST")),
"{:?}",
o.tax_accounts
);
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos")); assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
assert!(!o.company_addresses.is_empty()); assert!(!o.company_addresses.is_empty());
assert!(!o.naming_series.is_empty()); assert!(!o.naming_series.is_empty());
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()); assert!(
!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()
);
} }
// ---- Sales Invoice mapping ---- // ---- Sales Invoice mapping ----
@@ -361,10 +462,16 @@ async fn live_push_matrix() {
for (label, inv) in &cases { for (label, inv) in &cases {
live.archive(inv); live.archive(inv);
let r = live.push(inv, None).await; let r = live.push(inv, None).await;
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings); println!(
"{label}: {} -> {} (warnings {:?})",
inv.number, r.remote_name, r.warnings
);
assert_pushed(&r); assert_pushed(&r);
assert!(r.created && r.attached, "{label}: {r:?}"); assert!(r.created && r.attached, "{label}: {r:?}");
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number"); assert_eq!(
r.remote_name, inv.number,
"mirror naming keeps the Voiced number"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await; let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0); assert_eq!(doc["docstatus"], 0);
assert_matches(&live, inv, &doc).await; assert_matches(&live, inv, &doc).await;
@@ -376,11 +483,18 @@ async fn live_push_matrix() {
// Customer and address shape, as ERPNext stored them. // Customer and address shape, as ERPNext stored them.
let inter = &cases[1].1; let inter = &cases[1].1;
let doc = live.doc("Sales Invoice", &inter.number).await; let doc = live.doc("Sales Invoice", &inter.number).await;
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await; let customer = live
.doc("Customer", doc["customer"].as_str().unwrap())
.await;
assert_eq!(customer["customer_name"], "Voiced Live Inter Co"); assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
assert_eq!(customer["customer_type"], "Company"); assert_eq!(customer["customer_type"], "Company");
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await; let address = live
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})); .doc("Address", doc["customer_address"].as_str().unwrap())
.await;
println!(
"address: {}",
json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})
);
assert_eq!(address["state"], "Karnataka"); assert_eq!(address["state"], "Karnataka");
assert_eq!(address["country"], "India"); assert_eq!(address["country"], "India");
assert_eq!(address["address_type"], "Billing"); assert_eq!(address["address_type"], "Billing");
@@ -393,10 +507,17 @@ async fn live_push_matrix() {
} }
// A fresh local database (lost link) reuses the customer's address instead of creating another. // A fresh local database (lost link) reuses the customer's address instead of creating another.
let again = Live::new(|_| {}).await; let again = Live::new(|_| {}).await;
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({})); let inv = again.issue(
false,
json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&again.push(&inv, None).await); assert_pushed(&again.push(&inv, None).await);
let d2 = again.doc("Sales Invoice", &inv.number).await; let d2 = again.doc("Sales Invoice", &inv.number).await;
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused"); assert_eq!(
d2["customer_address"], doc["customer_address"],
"address reused"
);
assert_eq!(d2["customer"], doc["customer"]); assert_eq!(d2["customer"], doc["customer"]);
} }
@@ -408,7 +529,13 @@ async fn live_half_paise_rounding() {
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up. // (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the // Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
// first two are certain to differ under Banker's Rounding. // first two are certain to differ under Banker's Rounding.
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)]; let ties = [
(10.5, 0.0, true),
(7310.5, 0.0, true),
(3.5, 0.0, false),
(0.25, 0.0, false),
(99.99, 0.15, false),
];
for (amount, discount, tie) in ties { for (amount, discount, tie) in ties {
let inv = live.issue( let inv = live.issue(
true, true,
@@ -416,16 +543,31 @@ async fn live_half_paise_rounding() {
json!({"discount": discount}), json!({"discount": discount}),
); );
let r = live.push(&inv, Some(true)).await; let r = live.push(&inv, Some(true)).await;
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error); println!(
"{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}",
r.ok, r.error
);
if tie && banker { if tie && banker {
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted. // ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}"); assert!(
!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict),
"{r:?}"
);
let msg = r.error.as_deref().unwrap(); let msg = r.error.as_deref().unwrap();
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}"); assert!(
msg.contains("Commercial Rounding") && msg.contains("does not submit"),
"{msg}"
);
assert_eq!(r.remote_name, inv.number); assert_eq!(r.remote_name, inv.number);
let doc = live.doc("Sales Invoice", &r.remote_name).await; let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted"); assert_eq!(
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty()); doc["docstatus"], 0,
"a mismatching document is never submitted"
);
assert!(live
.files_of("Sales Invoice", &r.remote_name)
.await
.is_empty());
// Pushing again finds the same draft and reports the same conflict instead of creating another. // Pushing again finds the same draft and reports the same conflict instead of creating another.
let again = live.push(&inv, Some(true)).await; let again = live.push(&inv, Some(true)).await;
assert!(!again.ok && again.status == "conflict", "{again:?}"); assert!(!again.ok && again.status == "conflict", "{again:?}");
@@ -442,7 +584,11 @@ async fn live_half_paise_rounding() {
#[ignore = "needs the live ERPNext stack"] #[ignore = "needs the live ERPNext stack"]
async fn live_repush_is_idempotent() { async fn live_repush_is_idempotent() {
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({})); let inv = live.issue(
true,
json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]),
json!({}),
);
live.archive(&inv); live.archive(&inv);
let first = live.push(&inv, None).await; let first = live.push(&inv, None).await;
assert_pushed(&first); assert_pushed(&first);
@@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() {
assert_pushed(&again); assert_pushed(&again);
assert!(again.no_op && !again.created, "{again:?}"); assert!(again.no_op && !again.created, "{again:?}");
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated. // Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap(); live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let adopted = live.push(&inv, None).await; let adopted = live.push(&inv, None).await;
assert_pushed(&adopted); assert_pushed(&adopted);
assert!(adopted.attached, "{adopted:?}"); assert!(adopted.attached, "{adopted:?}");
@@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() {
assert_eq!(adopted.remote_name, first.remote_name); assert_eq!(adopted.remote_name, first.remote_name);
let all = live let all = live
.http .http
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc") .list_resource(
"Sales Invoice",
&["name"],
json!([["name", "=", inv.number.clone()]]),
"name asc",
)
.await .await
.unwrap(); .unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
@@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() {
async fn live_submit_paths() { async fn live_submit_paths() {
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
// Submit while creating. // Submit while creating.
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({})); let a = live.issue(
true,
json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
let r = live.push(&a, Some(true)).await; let r = live.push(&a, Some(true)).await;
assert_pushed(&r); assert_pushed(&r);
assert_eq!(r.remote_docstatus, 1); assert_eq!(r.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1); assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
// Submit an existing draft in a later push (mirror: v2 method route). // Submit an existing draft in a later push (mirror: v2 method route).
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({})); let b = live.issue(
true,
json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&b, Some(false)).await); assert_pushed(&live.push(&b, Some(false)).await);
let r = live.push(&b, Some(true)).await; let r = live.push(&b, Some(true)).await;
assert_pushed(&r); assert_pushed(&r);
@@ -494,20 +660,43 @@ async fn live_submit_paths() {
async fn live_series_naming_mode() { async fn live_series_naming_mode() {
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await; let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
let o = live.http.load_options(&live.cfg().company).await.unwrap(); let o = live.http.load_options(&live.cfg().company).await.unwrap();
let series = o.naming_series.first().expect("a naming series").name.clone(); let series = o
.naming_series
.first()
.expect("a naming series")
.name
.clone();
println!("series options: {:?}", o.naming_series); println!("series options: {:?}", o.naming_series);
live.save_cfg(|c| c.naming_series = series.clone()); live.save_cfg(|c| c.naming_series = series.clone());
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"})); let inv = live.issue(
true,
json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]),
json!({"notes": "note"}),
);
live.archive(&inv); live.archive(&inv);
let r = live.push(&inv, Some(false)).await; let r = live.push(&inv, Some(false)).await;
assert_pushed(&r); assert_pushed(&r);
assert!(r.created); assert!(r.created);
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it"); assert_ne!(
r.remote_name, inv.number,
"series mode lets ERPNext name it"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await; let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]); assert!(
doc["remarks"]
.as_str()
.unwrap()
.starts_with(&format!("Voiced invoice {}", inv.number)),
"{}",
doc["remarks"]
);
assert_matches(&live, &inv, &doc).await; assert_matches(&live, &inv, &doc).await;
// Lose the sync row: the remarks lookup finds the document instead of creating another. // Lose the sync row: the remarks lookup finds the document instead of creating another.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap(); live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let again = live.push(&inv, Some(false)).await; let again = live.push(&inv, Some(false)).await;
assert_pushed(&again); assert_pushed(&again);
assert!(!again.created, "{again:?}"); assert!(!again.created, "{again:?}");
@@ -517,7 +706,10 @@ async fn live_series_naming_mode() {
let s = live.push(&inv, Some(true)).await; let s = live.push(&inv, Some(true)).await;
assert_pushed(&s); assert_pushed(&s);
assert_eq!(s.remote_docstatus, 1); assert_eq!(s.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1); assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
} }
#[tokio::test] #[tokio::test]
@@ -529,18 +721,62 @@ async fn live_uom_and_quantities() {
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]); let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
let mut outcomes = Vec::new(); let mut outcomes = Vec::new();
for (label, desc, unit, qty) in [ for (label, desc, unit, qty) in [
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0), (
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5), "Hour row on a Nos item, whole qty",
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5), "Nos preset",
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0), "hour",
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5), 2.0,
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5), ),
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5), (
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0), "Hour row on a Nos item, fractional qty",
"Nos preset",
"hour",
1.5,
),
(
"Hour row on an Hour item, fractional qty",
"Hourly preset",
"hour",
1.5,
),
(
"Nos row on an Hour item, whole qty",
"Hourly preset",
"unit",
3.0,
),
(
"code-less hours, fractional qty",
"Code-less hours",
"hour",
1.5,
),
(
"code-less minutes, fractional qty",
"Code-less minutes",
"minute",
90.5,
),
(
"code-less Nos, fractional qty",
"Code-less nos",
"unit",
1.5,
),
(
"code-less Nos, whole qty",
"Code-less nos whole",
"unit",
4.0,
),
] { ] {
let inv = live.issue(true, row(desc, unit, qty), json!({})); let inv = live.issue(true, row(desc, unit, qty), json!({}));
let r = live.push(&inv, None).await; let r = live.push(&inv, None).await;
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default()); println!(
"{label}: ok={} {}",
r.ok,
r.error.clone().unwrap_or_default()
);
outcomes.push((label, r.ok)); outcomes.push((label, r.ok));
} }
println!("{outcomes:?}"); println!("{outcomes:?}");
@@ -548,7 +784,10 @@ async fn live_uom_and_quantities() {
assert!(ok("Hour row on an Hour item, fractional qty")); assert!(ok("Hour row on an Hour item, fractional qty"));
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty")); assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
assert!(ok("code-less Nos, whole qty")); assert!(ok("code-less Nos, whole qty"));
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM"); assert!(
!ok("code-less Nos, fractional qty"),
"ERPNext refuses a fraction of a whole-number UOM"
);
} }
// ---- payments ---- // ---- payments ----
@@ -569,8 +808,32 @@ async fn live_payment_entry() {
assert!(r.ok, "{:?}", r.error); assert!(r.ok, "{:?}", r.error);
let entry = r.entry_name.clone().unwrap(); let entry = r.entry_name.clone().unwrap();
let pe = live.doc("Payment Entry", &entry).await; let pe = live.doc("Payment Entry", &entry).await;
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"]; let keys = [
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>())); "payment_type",
"party_type",
"paid_from",
"paid_to",
"paid_amount",
"received_amount",
"base_paid_amount",
"base_received_amount",
"total_allocated_amount",
"unallocated_amount",
"difference_amount",
"reference_no",
"reference_date",
"posting_date",
"bank_account",
"mode_of_payment",
"status",
];
println!(
"PE {}",
json!(keys
.iter()
.map(|k| (k.to_string(), pe[*k].clone()))
.collect::<serde_json::Map<_, _>>())
);
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(",")); println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(",")); println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
assert_eq!(paise(&pe["paid_amount"]), cash); assert_eq!(paise(&pe["paid_amount"]), cash);
@@ -580,14 +843,29 @@ async fn live_payment_entry() {
let ded = pe["deductions"].as_array().unwrap(); let ded = pe["deductions"].as_array().unwrap();
assert_eq!(ded.len(), 1); assert_eq!(ded.len(), 1);
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str()); assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount"); assert_eq!(
paise(&ded[0]["amount"]),
tds,
"a TDS deduction row is a positive amount"
);
assert_eq!(pe["reference_no"], "UTR-LIVE-1"); assert_eq!(pe["reference_no"], "UTR-LIVE-1");
assert_eq!(pe["posting_date"], "2026-06-20"); assert_eq!(pe["posting_date"], "2026-06-20");
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account"); assert_eq!(
pe["paid_to"],
live.cfg().payment_bank_account.as_str(),
"money lands in the configured bank account"
);
let inv_doc = live.doc("Sales Invoice", &inv.number).await; let inv_doc = live.doc("Sales Invoice", &inv.number).await;
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]); println!(
"outstanding after payment: {}",
inv_doc["outstanding_amount"]
);
assert_eq!(pe["docstatus"], 1); assert_eq!(pe["docstatus"], 1);
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice"); assert_eq!(
paise(&inv_doc["outstanding_amount"]),
0,
"cash plus TDS settles the invoice"
);
// Idempotent locally. // Idempotent locally.
let again = push_payment(&live.db, &live.http, pid).await; let again = push_payment(&live.db, &live.http, pid).await;
assert!(again.ok && again.already_synced); assert!(again.ok && again.already_synced);
@@ -597,14 +875,22 @@ async fn live_payment_entry() {
#[ignore = "needs the live ERPNext stack"] #[ignore = "needs the live ERPNext stack"]
async fn live_payment_without_tds() { async fn live_payment_without_tds() {
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); let inv = live.issue(
true,
json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv, Some(true)).await); assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total); let total = crate::gst::rupees_to_paise(inv.total);
let pid = live.payment(&inv, total / 2, 0); let pid = live.payment(&inv, total / 2, 0);
let r = push_payment(&live.db, &live.http, pid).await; let r = push_payment(&live.db, &live.http, pid).await;
assert!(r.ok, "{:?}", r.error); assert!(r.ok, "{:?}", r.error);
let inv_doc = live.doc("Sales Invoice", &inv.number).await; let inv_doc = live.doc("Sales Invoice", &inv.number).await;
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding"); assert_eq!(
paise(&inv_doc["outstanding_amount"]),
total - total / 2,
"partial payment leaves the rest outstanding"
);
} }
// ---- India Compliance only (ERPNEXT_IC=1) ---- // ---- India Compliance only (ERPNEXT_IC=1) ----
@@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() {
} }
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let mut outcomes = Vec::new(); let mut outcomes = Vec::new();
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] { for (label, hsn) in [
("no HSN", ""),
("4-digit HSN", "9983"),
("6-digit HSN", "998314"),
("8-digit HSN", "99831400"),
] {
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({})); let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
let r = live.push(&inv, Some(true)).await; let r = live.push(&inv, Some(true)).await;
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error); println!(
"{label}: ok={} docstatus={} err={:?}",
r.ok, r.remote_docstatus, r.error
);
outcomes.push((label, r)); outcomes.push((label, r));
} }
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1; let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
@@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() {
// Voiced never issues such a number; this is a number imported or migrated from elsewhere. // Voiced never issues such a number; this is a number imported or migrated from elsewhere.
let long = format!("{}-EXTRA-LONG-PART", inv.number); let long = format!("{}-EXTRA-LONG-PART", inv.number);
assert!(long.len() > 16); assert!(long.len() > 16);
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap(); live.db
.lock()
.unwrap()
.execute(
"UPDATE invoices SET number = ?1 WHERE id = ?2",
params![long, inv.id],
)
.unwrap();
let r = live.push(&inv, None).await; let r = live.push(&inv, None).await;
println!("long number: ok={} err={:?}", r.ok, r.error); println!("long number: ok={} err={:?}", r.ok, r.error);
assert!(!r.ok); assert!(!r.ok);
@@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() {
return; return;
} }
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let local = LocalFacts { vendor_registered: true, ..Default::default() }; let local = LocalFacts {
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>(); vendor_registered: true,
..Default::default()
};
let codes = |ct: &ConnectionTest| {
ct.warnings
.iter()
.map(|w| w.code.clone())
.collect::<Vec<_>>()
};
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap(); let clean = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings); assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
let mut cfg = live.cfg(); let mut cfg = live.cfg();
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr); cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap(); let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings); assert_eq!(
codes(&wrong),
["gst_account_mismatch"],
"{:?}",
wrong.warnings
);
let mut cfg = live.cfg(); let mut cfg = live.cfg();
cfg.company = "No Such Co".into(); cfg.company = "No Such Co".into();
let other = test_connection(&live.http, &cfg, &local).await.unwrap(); let other = test_connection(&live.http, &cfg, &local).await.unwrap();
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings); assert!(
codes(&other).contains(&"gst_accounts_not_configured".to_string()),
"{:?}",
other.warnings
);
// Mirror mode with a number the site would refuse. // Mirror mode with a number the site would refuse.
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() }; let local = LocalFacts {
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap(); vendor_registered: true,
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings); next_number: Some("TOOLONG-INVOICE-0001".into()),
..Default::default()
};
let ct = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(
codes(&ct).contains(&"number_invalid".to_string()),
"{:?}",
ct.warnings
);
} }
#[tokio::test] #[tokio::test]
@@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() {
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not // India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning. // equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
assert_pushed(&r); assert_pushed(&r);
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings); assert!(
r.warnings.iter().any(|w| w.contains("reverse charge")),
"{:?}",
r.warnings
);
let doc = live.doc("Sales Invoice", &r.remote_name).await; let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["is_reverse_charge"], 0); assert_eq!(doc["is_reverse_charge"], 0);
assert_matches(&live, &inv, &doc).await; assert_matches(&live, &inv, &doc).await;
@@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() {
return; return;
} }
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap(); let v = live
.http
.get(
&["api", "method", "frappe.desk.form.load.getdoctype"],
&[("doctype", "Address".to_string())],
)
.await
.unwrap();
let docs = v["docs"].as_array().unwrap(); let docs = v["docs"].as_array().unwrap();
let address = docs.iter().find(|d| d["name"] == "Address").unwrap(); let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state"); let options = address["fields"]
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect(); .as_array()
.unwrap()
.iter()
.find(|f| f["fieldname"] == "gst_state")
.expect("IC adds gst_state");
let ic: Vec<&str> = options["options"]
.as_str()
.unwrap()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
for (code, name) in crate::gst::STATES { for (code, name) in crate::gst::STATES {
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}"); assert!(
ic.contains(name),
"India Compliance has no state named {name:?} ({code}); it has {ic:?}"
);
} }
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}"); assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
} }
@@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() {
#[ignore = "needs the live ERPNext stack"] #[ignore = "needs the live ERPNext stack"]
async fn live_payment_edge_cases() { async fn live_payment_edge_cases() {
let live = Live::new(|_| {}).await; let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); let inv = live.issue(
true,
json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
// Not submitted yet: refused locally, nothing sent. // Not submitted yet: refused locally, nothing sent.
assert_pushed(&live.push(&inv, Some(false)).await); assert_pushed(&live.push(&inv, Some(false)).await);
let early = live.payment(&inv, 100_00, 0); let early = live.payment(&inv, 100_00, 0);
let r = push_payment(&live.db, &live.http, early).await; let r = push_payment(&live.db, &live.http, early).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}"); assert!(
!r.ok && r.error.as_deref().unwrap().contains("not submitted"),
"{r:?}"
);
assert_pushed(&live.push(&inv, Some(true)).await); assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total); let total = crate::gst::rupees_to_paise(inv.total);
// TDS only (no cash): settled by deduction alone. // TDS only (no cash): settled by deduction alone.
let tds_only = live.payment(&inv, 0, total - 100_00); let tds_only = live.payment(&inv, 0, total - 100_00);
let r = push_payment(&live.db, &live.http, tds_only).await; let r = push_payment(&live.db, &live.http, tds_only).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}"); assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS only"),
"{r:?}"
);
// Without a configured TDS account a TDS payment is refused locally. // Without a configured TDS account a TDS payment is refused locally.
live.save_cfg(|c| c.tds_account = String::new()); live.save_cfg(|c| c.tds_account = String::new());
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); let inv2 = live.issue(
true,
json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv2, Some(true)).await); assert_pushed(&live.push(&inv2, Some(true)).await);
let t = live.payment(&inv2, 100_00, 50_00); let t = live.payment(&inv2, 100_00, 50_00);
let r = push_payment(&live.db, &live.http, t).await; let r = push_payment(&live.db, &live.http, t).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}"); assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS account"),
"{r:?}"
);
} }
+186 -42
View File
@@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String {
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> { fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
let tax_type = parse_tax_type(&inv.tax_type)?; let tax_type = parse_tax_type(&inv.tax_type)?;
let rate_bp = (inv.tax_rate * 100.0).round() as i64; let rate_bp = (inv.tax_rate * 100.0).round() as i64;
let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp); let totals = gst::compute_totals(
&inv.items,
gst::rupees_to_paise(inv.discount),
tax_type,
rate_bp,
);
let stored = [ let stored = [
(totals.cgst, inv.cgst_amount, "CGST"), (totals.cgst, inv.cgst_amount, "CGST"),
(totals.sgst, inv.sgst_amount, "SGST"), (totals.sgst, inv.sgst_amount, "SGST"),
@@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec<String> {
.enumerate() .enumerate()
.map(|(i, item)| { .map(|(i, item)| {
let trimmed = item.description.trim(); let trimmed = item.description.trim();
let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() }; let full = if trimmed.is_empty() {
format!("Item {}", i + 1)
} else {
trimmed.to_string()
};
let (head, tail) = first_line_and_rest(&full); let (head, tail) = first_line_and_rest(&full);
let mut candidate = full.clone(); let mut candidate = full.clone();
let mut n = 2; let mut n = 2;
while used.contains(&candidate.to_lowercase()) { while used.contains(&candidate.to_lowercase()) {
let head = format!("{head} ({n})"); let head = format!("{head} ({n})");
candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") }; candidate = if tail.is_empty() {
head
} else {
format!("{head}\n{tail}")
};
n += 1; n += 1;
} }
used.insert(candidate.to_lowercase()); used.insert(candidate.to_lowercase());
@@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
)); ));
} }
let (head, _) = first_line_and_rest(&descriptions[i]); let (head, _) = first_line_and_rest(&descriptions[i]);
row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX))); row.insert(
"item_name".into(),
json!(truncate_chars(head, ITEM_NAME_MAX)),
);
} }
} }
row.insert("description".into(), json!(descriptions[i])); row.insert("description".into(), json!(descriptions[i]));
@@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> { fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
if account.trim().is_empty() { if account.trim().is_empty() {
return Err(format!("The {head} account is not set in the ERPNext settings.")); return Err(format!(
"The {head} account is not set in the ERPNext settings."
));
} }
Ok(json!({ Ok(json!({
"charge_type": "On Net Total", "charge_type": "On Net Total",
@@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
})) }))
} }
fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result<Vec<Value>, String> { fn tax_rows(
cfg: &ErpnextConfig,
tax_type: TaxType,
rate_bp: i64,
supplier_state: &str,
) -> Result<Vec<Value>, String> {
match tax_type { match tax_type {
TaxType::None => Ok(Vec::new()), TaxType::None => Ok(Vec::new()),
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]), TaxType::Igst => Ok(vec![tax_row(
&cfg.igst_account,
"IGST",
&scaled_decimal(rate_bp, 2),
)?]),
TaxType::CgstSgst => { TaxType::CgstSgst => {
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5. // rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
let half = scaled_decimal(rate_bp * 5, 3); let half = scaled_decimal(rate_bp * 5, 3);
@@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state
} else { } else {
&cfg.sgst_account &cfg.sgst_account
}; };
Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?]) Ok(vec![
tax_row(&cfg.cgst_account, "CGST", &half)?,
tax_row(second_account, second, &half)?,
])
} }
} }
} }
@@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
let inv = ctx.invoice; let inv = ctx.invoice;
let cfg = ctx.config; let cfg = ctx.config;
if inv.status != "issued" { if inv.status != "issued" {
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status)); return Err(format!(
"Invoice {} is {}; only issued invoices are sent to ERPNext.",
inv.number, inv.status
));
} }
if ctx.customer.trim().is_empty() { if ctx.customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into()); return Err("The ERPNext customer is not resolved.".into());
@@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
} }
NamingMode::Series => { NamingMode::Series => {
if cfg.naming_series.trim().is_empty() { if cfg.naming_series.trim().is_empty() {
return Err("ERPNext series naming is selected but no naming series is set.".into()); return Err(
"ERPNext series naming is selected but no naming series is set.".into(),
);
} }
body.insert("naming_series".into(), json!(cfg.naming_series.trim())); body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
} }
@@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
body.insert("discount_amount".into(), money(totals.discount)); body.insert("discount_amount".into(), money(totals.discount));
} }
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?)); body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?)); body.insert(
"taxes".into(),
Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?),
);
if ctx.submit { if ctx.submit {
body.insert("docstatus".into(), json!(1)); body.insert("docstatus".into(), json!(1));
} }
let mirror = cfg.naming_mode == NamingMode::Mirror; let mirror = cfg.naming_mode == NamingMode::Mirror;
Ok(BuiltRequest { Ok(BuiltRequest {
path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 }, path: if mirror {
SALES_INVOICE_V2
} else {
SALES_INVOICE_V1
},
body: Value::Object(body), body: Value::Object(body),
idempotent: mirror, idempotent: mirror,
}) })
@@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool {
!g.is_empty() && !g.eq_ignore_ascii_case("NA") !g.is_empty() && !g.eq_ignore_ascii_case("NA")
} }
pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result<BuiltRequest, String> { pub fn build_customer(
client: &Client,
cfg: &ErpnextConfig,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
let name = client.name.trim(); let name = client.name.trim();
if name.is_empty() { if name.is_empty() {
return Err("The client has no name.".into()); return Err("The client has no name.".into());
@@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo
set_if(&mut body, "customer_group", &cfg.customer_group); set_if(&mut body, "customer_group", &cfg.customer_group);
set_if(&mut body, "territory", &cfg.territory); set_if(&mut body, "territory", &cfg.territory);
if india_compliance { if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?)); body.insert(
let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez"); "gst_category".into(),
json!(gst_category_label(&client.gst_category)?),
);
let taxable_registration = matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
);
if taxable_registration && has_gstin(client) { if taxable_registration && has_gstin(client) {
let gstin = client.gstin.trim().to_ascii_uppercase(); let gstin = client.gstin.trim().to_ascii_uppercase();
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?; gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
body.insert("gstin".into(), json!(gstin)); body.insert("gstin".into(), json!(gstin));
} }
} }
Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false }) Ok(BuiltRequest {
path: CUSTOMER,
body: Value::Object(body),
idempotent: false,
})
} }
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1"). /// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result<BuiltRequest, String> { pub fn build_address(
client: &Client,
customer: &str,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
if customer.trim().is_empty() { if customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into()); return Err("The ERPNext customer is not resolved.".into());
} }
@@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) ->
set_if(&mut body, "pincode", &client.pincode); set_if(&mut body, "pincode", &client.pincode);
body.insert("country".into(), json!("India")); body.insert("country".into(), json!("India"));
if india_compliance { if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?)); body.insert(
if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") { "gst_category".into(),
body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase())); json!(gst_category_label(&client.gst_category)?),
);
if has_gstin(client)
&& matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
{
body.insert(
"gstin".into(),
json!(client.gstin.trim().to_ascii_uppercase()),
);
} }
} }
body.insert( body.insert(
"links".into(), "links".into(),
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]), json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
); );
Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false }) Ok(BuiltRequest {
path: ADDRESS,
body: Value::Object(body),
idempotent: false,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -539,7 +614,13 @@ mod tests {
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap() Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
} }
fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option<String>], submit: bool) -> Result<BuiltRequest, String> { fn build(
inv: &Invoice,
cfg: &ErpnextConfig,
ic: bool,
codes: &[Option<String>],
submit: bool,
) -> Result<BuiltRequest, String> {
let v = vendor(inv); let v = vendor(inv);
build_sales_invoice(&InvoiceContext { build_sales_invoice(&InvoiceContext {
invoice: inv, invoice: inv,
@@ -597,7 +678,10 @@ mod tests {
assert_eq!(scaled_decimal(1500, 3), "1.5"); assert_eq!(scaled_decimal(1500, 3), "1.5");
assert_eq!(scaled_decimal(2625, 3), "2.625"); assert_eq!(scaled_decimal(2625, 3), "2.625");
assert_eq!(money(731_005).to_string(), "7310.05"); assert_eq!(money(731_005).to_string(), "7310.05");
assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07"); assert_eq!(
money(i64::from(u32::MAX) * 100 + 7).to_string(),
"4294967295.07"
);
} }
#[test] #[test]
@@ -607,7 +691,10 @@ mod tests {
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory"); assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries"); assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
for (code, name) in gst::STATES { for (code, name) in gst::STATES {
assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}")); assert_eq!(
place_of_supply_label(code).unwrap(),
format!("{code}-{name}")
);
} }
assert!(place_of_supply_label("28").is_err()); assert!(place_of_supply_label("28").is_err());
} }
@@ -750,10 +837,16 @@ mod tests {
#[test] #[test]
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() { fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
let mut conn = registered(); let mut conn = registered();
let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] })); let inv = issue(
&mut conn,
json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }),
);
let req = build(&inv, &cfg(), false, &[None], false).unwrap(); let req = build(&inv, &cfg(), false, &[None], false).unwrap();
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2); assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none()); assert!(
req.body.get("place_of_supply").is_none()
&& req.body.get("is_reverse_charge").is_none()
);
assert!(req.body["items"][0].get("gst_hsn_code").is_none()); assert!(req.body["items"][0].get("gst_hsn_code").is_none());
} }
@@ -795,8 +888,17 @@ mod tests {
let req = build(&inv, &cfg(), false, &codes, false).unwrap(); let req = build(&inv, &cfg(), false, &codes, false).unwrap();
let rows = req.body["items"].as_array().unwrap(); let rows = req.body["items"].as_array().unwrap();
assert!(rows[0].get("stock_uom").is_none()); assert!(rows[0].get("stock_uom").is_none());
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1))); assert_eq!(
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute"))); (
rows[0]["uom"].as_str(),
rows[0]["conversion_factor"].as_i64()
),
(Some("Hour"), Some(1))
);
assert_eq!(
(rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()),
(Some("Minute"), Some("Minute"))
);
} }
#[test] #[test]
@@ -849,7 +951,10 @@ mod tests {
assert!(!req.idempotent); assert!(!req.idempotent);
assert!(req.body.get("name").is_none()); assert!(req.body.get("name").is_none());
assert_eq!(req.body["naming_series"], "SINV-.YY.-"); assert_eq!(req.body["naming_series"], "SINV-.YY.-");
assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number)); assert_eq!(
req.body["remarks"],
format!("Voiced invoice {}\nNet 30", inv.number)
);
c.naming_series.clear(); c.naming_series.clear();
assert!(build(&inv, &c, true, &[None], false).is_err()); assert!(build(&inv, &c, true, &[None], false).is_err());
@@ -859,8 +964,15 @@ mod tests {
fn submit_adds_docstatus_and_drafts_do_not() { fn submit_adds_docstatus_and_drafts_do_not() {
let mut conn = registered(); let mut conn = registered();
let inv = issue(&mut conn, json!({})); let inv = issue(&mut conn, json!({}));
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none()); assert!(build(&inv, &cfg(), true, &[None], false)
assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1); .unwrap()
.body
.get("docstatus")
.is_none());
assert_eq!(
build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"],
1
);
} }
#[test] #[test]
@@ -874,7 +986,9 @@ mod tests {
// A supplier in a union territory without a legislature charges UTGST, not SGST. // A supplier in a union territory without a legislature charges UTGST, not SGST.
let mut ut = inv.clone(); let mut ut = inv.clone();
ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\""); ut.vendor_snapshot = ut
.vendor_snapshot
.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
let req = build(&ut, &cfg(), true, &[None], false).unwrap(); let req = build(&ut, &cfg(), true, &[None], false).unwrap();
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC"); assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%"); assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
@@ -901,11 +1015,17 @@ mod tests {
let mut inv = issue(&mut conn, json!({})); let mut inv = issue(&mut conn, json!({}));
let mut c = cfg(); let mut c = cfg();
c.cgst_account.clear(); c.cgst_account.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account")); assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("CGST account"));
c.company.clear(); c.company.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company")); assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("company"));
inv.status = "cancelled".into(); inv.status = "cancelled".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued")); assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap_err()
.contains("only issued"));
} }
#[test] #[test]
@@ -913,9 +1033,17 @@ mod tests {
let mut conn = registered(); let mut conn = registered();
let mut inv = issue(&mut conn, json!({})); let mut inv = issue(&mut conn, json!({}));
inv.due_date = "2026-03-01".into(); inv.due_date = "2026-03-01".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none()); assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
inv.due_date.clear(); inv.due_date.clear();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none()); assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
} }
#[test] #[test]
@@ -960,11 +1088,17 @@ mod tests {
} }
let mut unreg = client(); let mut unreg = client();
unreg.gst_category = "unregistered".into(); unreg.gst_category = "unregistered".into();
assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none()); assert!(build_customer(&unreg, &cfg(), true)
.unwrap()
.body
.get("gstin")
.is_none());
let mut bad = client(); let mut bad = client();
bad.gstin = "29AABCU9603R1Z0".into(); bad.gstin = "29AABCU9603R1Z0".into();
assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN")); assert!(build_customer(&bad, &cfg(), true)
.unwrap_err()
.contains("GSTIN"));
} }
#[test] #[test]
@@ -989,7 +1123,10 @@ mod tests {
); );
let mut jk = client(); let mut jk = client();
jk.state_code = "01".into(); jk.state_code = "01".into();
assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir"); assert_eq!(
build_address(&jk, "X", true).unwrap().body["state"],
"Jammu and Kashmir"
);
} }
#[test] #[test]
@@ -997,7 +1134,10 @@ mod tests {
let mut c = client(); let mut c = client();
c.address_line1.clear(); c.address_line1.clear();
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into(); c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street"); assert_eq!(
build_address(&c, "X", false).unwrap().body["address_line1"],
"5 Park Street"
);
let mut no_city = client(); let mut no_city = client();
no_city.city.clear(); no_city.city.clear();
@@ -1005,7 +1145,11 @@ mod tests {
let mut no_state = client(); let mut no_state = client();
no_state.state_code.clear(); no_state.state_code.clear();
assert!(build_address(&no_state, "X", true).is_err()); assert!(build_address(&no_state, "X", true).is_err());
assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none()); assert!(build_address(&no_state, "X", false)
.unwrap()
.body
.get("state")
.is_none());
assert!(build_address(&client(), " ", true).is_err()); assert!(build_address(&client(), " ", true).is_err());
} }
} }
+306 -65
View File
@@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode};
use super::discovery::ic_number_ok; use super::discovery::ic_number_ok;
use super::errors::{ErpError, ErrorKind}; use super::errors::{ErpError, ErrorKind};
use super::mapping::{ use super::mapping::{
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor, self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
SALES_INVOICE_V2, InvoiceContext, Vendor, SALES_INVOICE_V2,
}; };
use crate::commands::archive::read_archive_impl; use crate::commands::archive::read_archive_impl;
use crate::commands::invoice::get_invoice_impl; use crate::commands::invoice::get_invoice_impl;
@@ -31,14 +31,20 @@ use std::sync::Mutex;
type Db = Mutex<Connection>; type Db = Mutex<Connection>;
const DOCTYPE_INVOICE: &str = "Sales Invoice"; const DOCTYPE_INVOICE: &str = "Sales Invoice";
const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry"; const GET_PAYMENT_ENTRY: &str =
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
fn pre(message: impl Into<String>) -> ErpError { fn pre(message: impl Into<String>) -> ErpError {
ErpError::new(ErrorKind::Precondition, message) ErpError::new(ErrorKind::Precondition, message)
} }
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> { fn with_db<T>(
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?; db: &Db,
f: impl FnOnce(&mut Connection) -> Result<T, String>,
) -> Result<T, ErpError> {
let mut conn = db
.lock()
.map_err(|e| pre(format!("The database is busy: {e}")))?;
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}"))) f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
} }
@@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result<Vec<SyncStatus>, String> {
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash). /// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> { pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); const KEEP: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
let row = load_sync(conn, invoice_id)?; let row = load_sync(conn, invoice_id)?;
let name = row.map(|r| r.remote_name).unwrap_or_default(); let name = row.map(|r| r.remote_name).unwrap_or_default();
if name.is_empty() { if name.is_empty() {
@@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
} }
let cfg = config::load(conn)?; let cfg = config::load(conn)?;
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?; let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP))) Ok(format!(
"{base}/app/sales-invoice/{}",
utf8_percent_encode(&name, KEEP)
))
} }
// ---- loading ---- // ---- loading ----
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
address_line2: String::new(), address_line2: String::new(),
city: String::new(), city: String::new(),
pincode: String::new(), pincode: String::new(),
gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(), gst_category: if has_gstin {
"registered_regular"
} else {
"unregistered"
}
.into(),
default_notes: String::new(), default_notes: String::new(),
payment_terms_days: None, payment_terms_days: None,
invoice_count: 0, invoice_count: 0,
@@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
// ORDER BY id DESC: the oldest preset wins on a repeated description. // ORDER BY id DESC: the oldest preset wins on a repeated description.
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string()); by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
} }
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect()) Ok(inv
.items
.iter()
.map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned())
.collect())
} }
/// True when a connection test result is stored (blank, `null` and `{}` count as none). /// True when a connection test result is stored (blank, `null` and `{}` count as none).
@@ -353,8 +375,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
} }
} }
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?; let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot) let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?; fail(pre(format!(
"Invoice {number} has no supplier details recorded, so it cannot be sent."
)))
})?;
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the // A registered vendor's invoices go out with India Compliance fields, and those depend on what the
// connection test detected, so refuse until the test has run once. // connection test detected, so refuse until the test has run once.
if vendor.registered && !has_detect_result(&cfg.last_detect_result) { if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
@@ -382,7 +407,17 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
.ok() .ok()
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool)) .and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
.unwrap_or(false); .unwrap_or(false);
Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance }) Ok(Loaded {
cfg,
invoice,
vendor,
client,
item_codes,
sync,
pdf,
pdf_warning,
india_compliance,
})
} }
// ---- address checks (local, before anything is sent) ---- // ---- address checks (local, before anything is sent) ----
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
let g = client.gstin.trim(); let g = client.gstin.trim();
!g.is_empty() !g.is_empty()
&& !g.eq_ignore_ascii_case("NA") && !g.eq_ignore_ascii_case("NA")
&& matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") && matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
} }
/// The checks India Compliance would make on an Address, done here so the error is readable and local. /// The checks India Compliance would make on an Address, done here so the error is readable and local.
@@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> {
let pin = client.pincode.trim(); let pin = client.pincode.trim();
if !pin.is_empty() { if !pin.is_empty() {
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') { if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim())); return Err(format!(
"{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.",
client.name.trim()
));
} }
if let Some(allowed) = pin_prefixes(state_code) { if let Some(allowed) = pin_prefixes(state_code) {
if !allowed.iter().any(|p| pin.starts_with(p)) { if !allowed.iter().any(|p| pin.starts_with(p)) {
@@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 {
fn doc_total_paise(doc: &Value) -> Option<i64> { fn doc_total_paise(doc: &Value) -> Option<i64> {
let v = doc.get("grand_total")?; let v = doc.get("grand_total")?;
let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?; let n = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
Some(gst::rupees_to_paise(n)) Some(gst::rupees_to_paise(n))
} }
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
"creation asc", "creation asc",
) )
.await?; .await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string); found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
} }
if found.is_none() { if found.is_none() {
let rows = http let rows = http
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc") .list_resource(
"Customer",
&["name"],
json!([["customer_name", "=", name]]),
"creation asc",
)
.await?; .await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string); found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
} }
let customer = match found { let customer = match found {
Some(c) => c, Some(c) => c,
@@ -521,12 +577,17 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?; let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
let resp = http.post(req.path, &req.body, req.idempotent).await?; let resp = http.post(req.path, &req.body, req.idempotent).await?;
// A duplicate name comes back as "X - 1": always use what the server returned. // A duplicate name comes back as "X - 1": always use what the server returned.
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))? doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new customer's name.")
})?
} }
}; };
if let Some(id) = l.client.id { if let Some(id) = l.client.id {
with_db(db, |c| { with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id]) c.execute(
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
params![customer, id],
)
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
})?; })?;
@@ -548,16 +609,27 @@ async fn ensure_address(
let Some(client_id) = l.client.id else { let Some(client_id) = l.client.id else {
return Ok(( return Ok((
None, None,
Some("The client is not saved, so the invoice was sent without a customer address.".into()), Some(
"The client is not saved, so the invoice was sent without a customer address."
.into(),
),
)); ));
}; };
let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode] let has_address = [
&client.address_line1,
&client.address,
&client.city,
&client.pincode,
]
.iter() .iter()
.any(|s| !s.trim().is_empty()); .any(|s| !s.trim().is_empty());
if !has_address { if !has_address {
return Ok(( return Ok((
None, None,
Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())), Some(format!(
"{} has no address saved, so the invoice was sent without a customer address.",
client.name.trim()
)),
)); ));
} }
validate_address(client).map_err(pre)?; validate_address(client).map_err(pre)?;
@@ -568,11 +640,16 @@ async fn ensure_address(
Some(existing) => existing, Some(existing) => existing,
None => { None => {
let resp = http.post(req.path, &req.body, req.idempotent).await?; let resp = http.post(req.path, &req.body, req.idempotent).await?;
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))? doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new address's name.")
})?
} }
}; };
with_db(db, |c| { with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id]) c.execute(
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
params![name, client_id],
)
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
})?; })?;
@@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<
if let Some(pin) = body.get("pincode").and_then(Value::as_str) { if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
filters.push(json!(["pincode", "=", pin])); filters.push(json!(["pincode", "=", pin]));
} }
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?; let rows = http
.list_resource(
"Address",
&["name"],
Value::Array(filters),
"`tabAddress`.creation asc",
)
.await
.ok()?;
rows.first()?.get("name")?.as_str().map(str::to_string) rows.first()?.get("name")?.as_str().map(str::to_string)
} }
@@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
.list_resource( .list_resource(
DOCTYPE_INVOICE, DOCTYPE_INVOICE,
&["name", "docstatus", "grand_total", "remarks"], &["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]), json!([
["remarks", "like", format!("{marker}%")],
["docstatus", "!=", 2]
]),
"creation asc", "creation asc",
) )
.await?; .await?;
@@ -674,7 +762,11 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
} }
} }
async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result<RemoteDoc, ErpError> { async fn create_or_find(
http: &ErpClient,
l: &Loaded,
body: &mapping::BuiltRequest,
) -> Result<RemoteDoc, ErpError> {
let inv = &l.invoice; let inv = &l.invoice;
if l.cfg.naming_mode == NamingMode::Series { if l.cfg.naming_mode == NamingMode::Series {
if let Some(found) = find_by_remarks(http, l).await? { if let Some(found) = find_by_remarks(http, l).await? {
@@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
(Some(n), _) => n, (Some(n), _) => n,
(None, NamingMode::Mirror) => inv.number.clone(), (None, NamingMode::Mirror) => inv.number.clone(),
(None, NamingMode::Series) => { (None, NamingMode::Series) => {
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name.")) return Err(ErpError::protocol(
"ERPNext did not return the new Sales Invoice's name.",
))
} }
}; };
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) }) Ok(RemoteDoc {
name,
docstatus: doc_docstatus(&doc),
created: true,
total_paise: doc_total_paise(&doc),
})
} }
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => { Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
// The mirrored name is taken: either a repeat of an earlier push or someone else's document. // The mirrored name is taken: either a repeat of an earlier push or someone else's document.
@@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
http.post(&path, &json!({}), false).await? http.post(&path, &json!({}), false).await?
} }
NamingMode::Series => { NamingMode::Series => {
http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await? http.post(
&["api", "resource", DOCTYPE_INVOICE, name],
&json!({ "run_method": "submit" }),
false,
)
.await?
} }
}; };
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not. // A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) { match resp
.get("data")
.and_then(|d| d.get("docstatus"))
.and_then(Value::as_i64)
{
Some(1) | None => Ok(1), Some(1) | None => Ok(1),
Some(other) => Err(ErpError::protocol(format!( Some(other) => Err(ErpError::protocol(format!(
"ERPNext accepted the submit request but the document is still at docstatus {other}." "ERPNext accepted the submit request but the document is still at docstatus {other}."
@@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
fn attachment_file_name(number: &str) -> String { fn attachment_file_name(number: &str) -> String {
let cleaned: String = number let cleaned: String = number
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }) .map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'-'
}
})
.collect(); .collect();
let cleaned = cleaned.trim_matches('-'); let cleaned = cleaned.trim_matches('-');
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned }) format!(
"{}.pdf",
if cleaned.is_empty() {
"invoice"
} else {
cleaned
}
)
} }
/// True when the document already carries a file of this name. Used when the local row lost its attachment /// True when the document already carries a file of this name. Used when the local row lost its attachment
/// hash (a re-adopted document), so the same PDF is not attached twice. /// hash (a re-adopted document), so the same PDF is not attached twice.
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> { async fn has_attachment(
http: &ErpClient,
remote_name: &str,
file_name: &str,
) -> Result<bool, ErpError> {
let rows = http let rows = http
.list_resource( .list_resource(
"File", "File",
@@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) ->
} }
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached. /// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> { async fn attach_pdf(
http: &ErpClient,
l: &Loaded,
remote_name: &str,
pdf: &Pdf,
adopted: bool,
) -> Result<(), ErpError> {
let file_name = attachment_file_name(&l.invoice.number); let file_name = attachment_file_name(&l.invoice.number);
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) { if adopted
&& has_attachment(http, remote_name, &file_name)
.await
.unwrap_or(false)
{
return Ok(()); return Ok(());
} }
let fields = [ let fields = [
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
let resp = http let resp = http
.post_file( .post_file(
&["api", "method", "upload_file"], &["api", "method", "upload_file"],
&Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields }, &Upload {
file_name: &file_name,
mime: "application/pdf",
bytes: &pdf.bytes,
fields: &fields,
},
) )
.await?; .await?;
let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false); let ok = resp
.get("message")
.map(|m| m.get("name").is_some() || m.get("file_url").is_some())
.unwrap_or(false);
if ok { if ok {
Ok(()) Ok(())
} else { } else {
Err(ErpError::protocol("ERPNext did not confirm the PDF upload.")) Err(ErpError::protocol(
"ERPNext did not confirm the PDF upload.",
))
} }
} }
@@ -797,7 +942,14 @@ struct Progress {
warnings: Vec<String>, warnings: Vec<String>,
} }
fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> { fn persist(
db: &Db,
invoice_id: i64,
prev: Option<&SyncRow>,
st: &Progress,
status: &str,
error: &str,
) -> Result<(), ErpError> {
let synced_at = if status == "synced" { let synced_at = if status == "synced" {
Some(chrono::Utc::now().to_rfc3339()) Some(chrono::Utc::now().to_rfc3339())
} else { } else {
@@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and /// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
/// conflicts already say what is wrong and get no prefix. /// conflicts already say what is wrong and get no prefix.
fn failure_text(step: &str, e: &ErpError) -> String { fn failure_text(step: &str, e: &ErpError) -> String {
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) { if step.is_empty()
|| matches!(
e.kind,
ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict
)
{
e.to_string() e.to_string()
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") { } else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
// ERPNext names the row and the UOM already; say what to change in Voiced's settings. // ERPNext names the row and the UOM already; say what to change in Voiced's settings.
@@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String {
} }
} }
async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> { async fn run_push(
db: &Db,
http: &ErpClient,
l: &Loaded,
want_submit: bool,
st: &mut Progress,
) -> Result<(), ErpError> {
let inv = &l.invoice; let inv = &l.invoice;
let cfg = &l.cfg; let cfg = &l.cfg;
let prev = l.sync.as_ref(); let prev = l.sync.as_ref();
@@ -882,7 +1045,10 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
} }
None => { None => {
st.payload_hash = hash; st.payload_hash = hash;
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) { if cfg.naming_mode == NamingMode::Mirror
&& l.india_compliance
&& !ic_number_ok(&inv.number)
{
return Err(pre(format!( return Err(pre(format!(
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.", "Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
inv.number inv.number
@@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) { if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
st.warnings.push(w.clone()); st.warnings.push(w.clone());
} }
if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() { if existing.is_some()
&& prev.is_some_and(|p| p.status == "synced")
&& !need_submit
&& pdf.is_none()
{
st.no_op = true; st.no_op = true;
return Ok(()); return Ok(());
} }
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
Ok(()) Ok(())
} }
pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option<bool>) -> PushResult { pub async fn push_invoice(
db: &Db,
local_dir: &Path,
http: &ErpClient,
invoice_id: i64,
submit: Option<bool>,
) -> PushResult {
let loaded = match load_for_push(db, local_dir, invoice_id) { let loaded = match load_for_push(db, local_dir, invoice_id) {
Ok(l) => l, Ok(l) => l,
Err((number, e)) => return PushResult::refused(invoice_id, &number, e), Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
@@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
let (error, status): (Option<(ErpError, String)>, &str) = match outcome { let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
Ok(()) => { Ok(()) => {
let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") }; let write = if st.no_op {
Ok(())
} else {
persist(db, invoice_id, prev, &st, "synced", "")
};
match write { match write {
Ok(()) => (None, "synced"), Ok(()) => (None, "synced"),
Err(e) => { Err(e) => {
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
} }
Err(e) => { Err(e) => {
let text = failure_text(st.step, &e); let text = failure_text(st.step, &e);
let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" }; let status = if e.kind == ErrorKind::Conflict {
"conflict"
} else {
"error"
};
// Best effort: the original error is what the caller needs to see. // Best effort: the original error is what the caller needs to see.
let _ = persist(db, invoice_id, prev, &st, status, &text); let _ = persist(db, invoice_id, prev, &st, status, &text);
(Some((e, text)), status) (Some((e, text)), status)
@@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> {
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by /// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
/// cash + TDS. A partial payment without TDS leaves the rest outstanding. /// cash + TDS. A partial payment without TDS leaves the rest outstanding.
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> { pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?; let mut doc: Map<String, Value> = draft
.as_object()
.cloned()
.ok_or("ERPNext returned an unexpected payment draft.")?;
doc.retain(|k, _| !k.starts_with("__")); doc.retain(|k, _| !k.starts_with("__"));
doc.insert("doctype".into(), json!("Payment Entry")); doc.insert("doctype".into(), json!("Payment Entry"));
doc.insert("posting_date".into(), json!(p.paid_on)); doc.insert("posting_date".into(), json!(p.paid_on));
let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() }; let reference = if p.reference.trim().is_empty() {
format!("Voiced payment {}", p.payment_id)
} else {
p.reference.trim().to_string()
};
doc.insert("reference_no".into(), json!(reference)); doc.insert("reference_no".into(), json!(reference));
doc.insert("reference_date".into(), json!(p.paid_on)); doc.insert("reference_date".into(), json!(p.paid_on));
doc.insert("paid_amount".into(), mapping::money(p.cash_paise)); doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
doc.insert("received_amount".into(), mapping::money(p.cash_paise)); doc.insert("received_amount".into(), mapping::money(p.cash_paise));
doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number))); doc.insert(
"remarks".into(),
json!(format!(
"Voiced payment {} for invoice {}",
p.payment_id, p.invoice_number
)),
);
let allocated = mapping::money(p.cash_paise + p.tds_paise); let allocated = mapping::money(p.cash_paise + p.tds_paise);
let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?; let refs = doc
.get_mut("references")
.and_then(Value::as_array_mut)
.ok_or("ERPNext returned no invoice reference for this payment.")?;
let target = refs let target = refs
.iter_mut() .iter_mut()
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice)) .find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
.ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?; .ok_or_else(|| {
format!(
"ERPNext's payment draft does not reference {}.",
p.remote_invoice
)
})?;
target["allocated_amount"] = allocated; target["allocated_amount"] = allocated;
let deductions = if p.tds_paise > 0 { let deductions = if p.tds_paise > 0 {
@@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "Payment not found".to_string())?; .ok_or_else(|| "Payment not found".to_string())?;
let number: String = conn let number: String = conn
.query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0)) .query_row(
"SELECT number FROM invoices WHERE id = ?1",
params![row.invoice_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let sync = load_sync(conn, row.invoice_id)?; let sync = load_sync(conn, row.invoice_id)?;
let cfg = config::load(conn)?; let cfg = config::load(conn)?;
@@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
)); ));
} }
if cfg.payment_bank_account.trim().is_empty() { if cfg.payment_bank_account.trim().is_empty() {
return fail(pre("Set the payment bank account in the ERPNext settings first.")); return fail(pre(
"Set the payment bank account in the ERPNext settings first.",
));
} }
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() { if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first.")); return fail(pre(
"This payment has TDS: set the TDS account in the ERPNext settings first.",
));
} }
let query = [ let query = [
("dt", DOCTYPE_INVOICE.to_string()), ("dt", DOCTYPE_INVOICE.to_string()),
("dn", remote_invoice.clone()), ("dn", remote_invoice.clone()),
("bank_account", cfg.payment_bank_account.trim().to_string()), ("bank_account", cfg.payment_bank_account.trim().to_string()),
("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)), (
"party_amount",
paise_to_decimal(row.amount_paise + row.tds_paise),
),
]; ];
let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await { let draft = match http
.get(&["api", "method", GET_PAYMENT_ENTRY], &query)
.await
{
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null), Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e), Err(e) => {
return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e)
}
}; };
let body = match build_payment_entry( let body = match build_payment_entry(
&draft, &draft,
@@ -1178,15 +1399,25 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
Err(e) => return fail(ErpError::protocol(e)), Err(e) => return fail(ErpError::protocol(e)),
}; };
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count. // Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await { let resp = match http
.post(&["api", "resource", "Payment Entry"], &body, false)
.await
{
Ok(v) => v, Ok(v) => v,
Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e), Err(e) => {
return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e)
}
}; };
let Some(name) = doc_name(&resp) else { let Some(name) = doc_name(&resp) else {
return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name.")); return fail(ErpError::protocol(
"ERPNext did not return the new Payment Entry's name.",
));
}; };
if let Err(e) = with_db(db, |c| { if let Err(e) = with_db(db, |c| {
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id]) c.execute(
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
params![name, payment_id],
)
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) { }) {
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
request: PushRequest, request: PushRequest,
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a { ) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
async move { async move {
let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await; let r = push_invoice(
self.db,
self.local_dir,
self.http,
request.invoice_id,
request.submit,
)
.await;
if r.ok { if r.ok {
Ok(PushedInvoice { Ok(PushedInvoice {
remote_name: r.remote_name, remote_name: r.remote_name,
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
async move { async move {
let r = push_payment(self.db, self.http, request.payment_id).await; let r = push_payment(self.db, self.http, request.payment_id).await;
match (r.ok, r.entry_name) { match (r.ok, r.entry_name) {
(true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }), (true, Some(name)) => Ok(PushedPayment {
remote_name: name,
created: !r.already_synced,
}),
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())), _ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
} }
} }
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -18,7 +18,12 @@ pub struct Reply {
} }
pub fn reply(status: u16, body: Value) -> Reply { pub fn reply(status: u16, body: Value) -> Reply {
Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 } Reply {
status,
headers: vec![],
body: body.to_string(),
delay_ms: 0,
}
} }
pub struct Mock { pub struct Mock {
@@ -33,7 +38,12 @@ pub async fn serve(script: Vec<Reply>) -> Mock {
let script = Arc::new(script); let script = Arc::new(script);
serve_fn(move |i, _| { serve_fn(move |i, _| {
let r = &script[i.min(script.len() - 1)]; let r = &script[i.min(script.len() - 1)];
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms } Reply {
status: r.status,
headers: r.headers.clone(),
body: r.body.clone(),
delay_ms: r.delay_ms,
}
}) })
.await .await
} }
@@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
let handler = Arc::new(handler); let handler = Arc::new(handler);
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
let Ok((mut sock, _)) = listener.accept().await else { return }; let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let (h, r, handler) = (h.clone(), r.clone(), handler.clone()); let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
tokio::spawn(async move { tokio::spawn(async move {
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
}); });
} }
}); });
Mock { base: format!("http://127.0.0.1:{port}"), hits, requests } Mock {
base: format!("http://127.0.0.1:{port}"),
hits,
requests,
}
} }
/// Short timeouts and near-zero backoff so retry tests run in milliseconds. /// Short timeouts and near-zero backoff so retry tests run in milliseconds.
@@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions {
} }
pub fn client(mock: &Mock) -> ErpClient { pub fn client(mock: &Mock) -> ErpClient {
ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap() ErpClient::new(
&mock.base,
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap()
} }
+37 -8
View File
@@ -38,23 +38,39 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
let data_dir = match app.path().app_data_dir() { let data_dir = match app.path().app_data_dir() {
Ok(dir) => dir, Ok(dir) => dir,
Err(e) => { Err(e) => {
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None); show_startup_error(
app,
&format!("Could not resolve the application data directory: {e}"),
None,
);
return Err(e.into()); return Err(e.into());
} }
}; };
if let Err(e) = std::fs::create_dir_all(&data_dir) { if let Err(e) = std::fs::create_dir_all(&data_dir) {
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir)); show_startup_error(
app,
&format!("Could not create the application data directory: {e}"),
Some(&data_dir),
);
return Err(e.into()); return Err(e.into());
} }
let local_data_dir = match app.path().app_local_data_dir() { let local_data_dir = match app.path().app_local_data_dir() {
Ok(dir) => dir, Ok(dir) => dir,
Err(e) => { Err(e) => {
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir)); show_startup_error(
app,
&format!("Could not resolve the local data directory: {e}"),
Some(&data_dir),
);
return Err(e.into()); return Err(e.into());
} }
}; };
if let Err(e) = std::fs::create_dir_all(&local_data_dir) { if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir)); show_startup_error(
app,
&format!("Could not create the local data directory: {e}"),
Some(&local_data_dir),
);
return Err(e.into()); return Err(e.into());
} }
// A restore staged by the Data tab is swapped in now, before anything opens the database. // A restore staged by the Data tab is swapped in now, before anything opens the database.
@@ -62,19 +78,32 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) { match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {} commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => { commands::backup::ApplyOutcome::Applied { safety_dir } => {
eprintln!("Backup restored; the previous data is in {}", safety_dir.display()); eprintln!(
"Backup restored; the previous data is in {}",
safety_dir.display()
);
}
commands::backup::ApplyOutcome::Failed { message } => {
eprintln!("Backup restore failed: {message}")
} }
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
} }
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) { let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) => { Err(e) => {
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir)); show_startup_error(
app,
&format!("Could not open the database: {e}"),
Some(&data_dir),
);
return Err(e.into()); return Err(e.into());
} }
}; };
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) { if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir)); show_startup_error(
app,
&format!("Could not update stored image paths: {e}"),
Some(&data_dir),
);
return Err(e.into()); return Err(e.into());
} }
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries. // Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
+43 -12
View File
@@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) {
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> { fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive); let encoder =
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
img.write_with_encoder(encoder) img.write_with_encoder(encoder)
.map_err(|e| LogoError::Encode(e.to_string()))?; .map_err(|e| LogoError::Encode(e.to_string()))?;
Ok(buf) Ok(buf)
@@ -360,7 +361,9 @@ mod tests {
} }
fn decode_png(bytes: &[u8]) -> RgbaImage { fn decode_png(bytes: &[u8]) -> RgbaImage {
image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8() image::load_from_memory_with_format(bytes, ImageFormat::Png)
.unwrap()
.to_rgba8()
} }
#[test] #[test]
@@ -427,7 +430,8 @@ mod tests {
assert!((38..=42).contains(&m.height), "{}", m.height); assert!((38..=42).contains(&m.height), "{}", m.height);
assert!(out.knockout_png.is_none()); assert!(out.knockout_png.is_none());
// The input pixels are not edited: the printed corners are still the logo colour, not transparent. // The input pixels are not edited: the printed corners are still the logo colour, not transparent.
let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap(); let printed =
image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
assert!(!printed.color().has_alpha()); assert!(!printed.color().has_alpha());
} }
@@ -459,7 +463,10 @@ mod tests {
// Right magic, broken body. // Right magic, broken body.
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3])); let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
truncated.truncate(30); truncated.truncate(30);
assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_)))); assert!(matches!(
process_logo(&truncated),
Err(LogoError::Decode(_))
));
} }
#[test] #[test]
@@ -470,14 +477,23 @@ mod tests {
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap. // A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
let wide = RgbaImage::new(8193, 2); let wide = RgbaImage::new(8193, 2);
assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions))); assert!(matches!(
process_logo(&png_bytes(&wide)),
Err(LogoError::Dimensions)
));
} }
#[test] #[test]
fn blank_images_have_no_content() { fn blank_images_have_no_content() {
assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty))); assert!(matches!(
process_logo(&png_bytes(&RgbaImage::new(40, 40))),
Err(LogoError::Empty)
));
let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255])); let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255]));
assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty))); assert!(matches!(
process_logo(&png_bytes(&white)),
Err(LogoError::Empty)
));
} }
#[test] #[test]
@@ -508,9 +524,15 @@ mod tests {
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density); assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]); let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark); assert_eq!(
process_logo(&png_bytes(&square)).unwrap().metrics.kind,
LogoKind::Mark
);
let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]); let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]);
assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall); assert_eq!(
process_logo(&png_bytes(&tall)).unwrap().metrics.kind,
LogoKind::Tall
);
assert_eq!(kind_for(2.49), LogoKind::Mark); assert_eq!(kind_for(2.49), LogoKind::Mark);
assert_eq!(kind_for(0.6), LogoKind::Tall); assert_eq!(kind_for(0.6), LogoKind::Tall);
} }
@@ -535,15 +557,24 @@ mod tests {
#[test] #[test]
fn sample_logo_is_trimmed_and_small() { fn sample_logo_is_trimmed_and_small() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png"); let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
let bytes = std::fs::read(path).unwrap(); let bytes = std::fs::read(path).unwrap();
let out = process_logo(&bytes).unwrap(); let out = process_logo(&bytes).unwrap();
let m = &out.metrics; let m = &out.metrics;
assert_eq!((m.source_width, m.source_height), (2172, 724)); assert_eq!((m.source_width, m.source_height), (2172, 724));
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect); assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
assert!((0.50..=0.60).contains(&m.ink_density), "density {}", m.ink_density); assert!(
(0.50..=0.60).contains(&m.ink_density),
"density {}",
m.ink_density
);
assert_eq!(m.kind, LogoKind::Wordmark); assert_eq!(m.kind, LogoKind::Wordmark);
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len()); assert!(
out.print_png.len() <= 120 * 1024,
"print is {} bytes",
out.print_png.len()
);
assert!(out.knockout_png.is_some()); assert!(out.knockout_png.is_some());
} }
} }
+7 -3
View File
@@ -7,14 +7,18 @@ use std::path::{Path, PathBuf};
const ENV_VAR: &str = "VOICED_SELFTEST_OUT"; const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
fn configured_path() -> Option<PathBuf> { fn configured_path() -> Option<PathBuf> {
std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from) std::env::var_os(ENV_VAR)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
} }
fn write_report(path: &Path, json: &str) -> Result<(), String> { fn write_report(path: &Path, json: &str) -> Result<(), String> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?; std::fs::create_dir_all(parent)
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
} }
let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?; let mut file =
std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
file.write_all(json.as_bytes()) file.write_all(json.as_bytes())
.and_then(|()| file.sync_all()) .and_then(|()| file.sync_all())
.map_err(|e| format!("Could not write the report: {e}")) .map_err(|e| format!("Could not write the report: {e}"))
+1 -2
View File
@@ -102,7 +102,6 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
}, },
); );
let inv!: Invoice;
const issued = await log.run("e2e: issue and archive", async () => { const issued = await log.run("e2e: issue and archive", async () => {
const outcome = await issueAndArchive(input, prefs, deps); const outcome = await issueAndArchive(input, prefs, deps);
invoice = outcome.invoice; invoice = outcome.invoice;
@@ -112,7 +111,7 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
}; };
}); });
if (!issued.ok || !invoice) return; if (!issued.ok || !invoice) return;
inv = invoice as Invoice; const inv = invoice as Invoice;
await log.run("e2e: archive status", async () => { await log.run("e2e: archive status", async () => {
const st = await api.archiveStatus(inv.id); const st = await api.archiveStatus(inv.id);
+1
View File
@@ -22,6 +22,7 @@ export function sanitize(value: string | null | undefined): string {
.replace(/\r\n?/g, "\n") .replace(/\r\n?/g, "\n")
.replace(/[\t\n\v\f]/g, " ") .replace(/[\t\n\v\f]/g, " ")
.replace(/ /g, " ") .replace(/ /g, " ")
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
.replace(/[\u0000-\u001F\u007F-\u009F​]/g, "") .replace(/[\u0000-\u001F\u007F-\u009F​]/g, "")
.replace(/ {2,}/g, " ") .replace(/ {2,}/g, " ")
.trim(); .trim();
+1 -1
View File
@@ -21,7 +21,7 @@ import { RenderTxt } from "./blocks/RenderTxt";
import { TableGuard, guardPresence } from "./blocks/TableGuard"; import { TableGuard, guardPresence } from "./blocks/TableGuard";
import { renderCore } from "./render/core"; import { renderCore } from "./render/core";
import { loadCanvas, inkPixels } from "./testing/rasterize"; import { loadCanvas, inkPixels } from "./testing/rasterize";
import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs"; import { byId, doc, FAMILY, find, frameFor, PAD, rawLayout, txt } from "./testing/docs";
import { FONTS_DIR, setupPdfTest } from "./testing/setup"; import { FONTS_DIR, setupPdfTest } from "./testing/setup";
import { stack } from "./fonts/register"; import { stack } from "./fonts/register";
-2
View File
@@ -15,8 +15,6 @@ export const REVERSE_GAP = 4;
export const HEADING_AFTER = 4; export const HEADING_AFTER = 4;
/** Space between the closing block's two columns. */ /** Space between the closing block's two columns. */
export const CLOSING_COLUMN_GAP = 16; export const CLOSING_COLUMN_GAP = 16;
/** Slate: space between a mark and the business name in the dark band. */
const NAME_AFTER_LOGO = 7.8;
/** Space above and below the thin rule that separates the stack from TOTAL. */ /** Space above and below the thin rule that separates the stack from TOTAL. */
export const STACK_RULE = { before: 2, after: 8, thickness: 0.75 }; export const STACK_RULE = { before: 2, after: 8, thickness: 0.75 };
const MIN_NAME_W = 40; const MIN_NAME_W = 40;
+2 -10
View File
@@ -1,17 +1,9 @@
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AuditIssue } from "../engine/audit";
import { PAGE_SIZES } from "../engine/geometry";
import { PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, CLOSING_ID, TOTALS_ID } from "../engine/ids";
import { walk, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
import { forbiddenProbe, SLOT_RULES, slotContent, type SlotName } from "../model/slots";
import { frameForPrefs, renderInvoicePdf } from "../render/invoice";
import { loadCanvas, pagePng } from "../testing/rasterize"; import { loadCanvas, pagePng } from "../testing/rasterize";
import { SLOT_ID } from "../blocks/slots"; import { renderInvoicePdf } from "../render/invoice";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "./classic/ids"; import { FIXTURES, buildFixture, ensureSetup, PAGE_SIZE_IDS, prefsFor, renderFixture, type FixtureSpec } from "./harness";
import { HEADER_GAP, headerCols } from "./classic/plan";
import { FIXTURE_GSTIN, FIXTURES, buildFixture, ensureSetup, makeItems, PAGE_SIZE_IDS, prefsFor, renderFixture, type FixtureSpec, type Rendered } from "./harness";
import { TEMPLATES } from "./registry"; import { TEMPLATES } from "./registry";
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
import { Image, View } from "@react-pdf/renderer"; import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { colsDetailed, pinned } from "../../engine/columns"; import { pinned } from "../../engine/columns";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids"; import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth"; import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer"; import { BottomSpacer } from "../../blocks/BottomSpacer";
+1 -1
View File
@@ -202,7 +202,7 @@ function Parties({ ctx }: { ctx: Ctx }) {
const blocks: ReactNode[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />]; const blocks: ReactNode[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />];
let k = 1; let k = 1;
if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />); if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />);
if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k++]} />); if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k]} />);
const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w })); const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
return ( return (
<View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}> <View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}>
+1 -1
View File
@@ -151,7 +151,7 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian
let k = 0; let k = 0;
const clientW = textW(k++); const clientW = textW(k++);
const bankW = hasBank ? textW(k++) : 0; const bankW = hasBank ? textW(k++) : 0;
const idsW = hasIds ? textW(k++) : 0; const idsW = hasIds ? textW(k) : 0;
const head = leading(r.heading) + v.parties.headingGap; const head = leading(r.heading) + v.parties.headingGap;
let clientH = head + textHeight(r.body, c.name, clientW); let clientH = head + textHeight(r.body, c.name, clientW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW); for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW);
+1 -1
View File
@@ -296,7 +296,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
for (const l of lines) payH += textHeight(r.address, l, payW); for (const l of lines) payH += textHeight(r.address, l, payW);
} }
if (v.id === "serenity") signH += leading(r.label) + 4; if (v.id === "serenity") signH += leading(r.label) + 4;
let closingH = 0; let closingH: number;
if (v.payTo.where === "closing") closingH = Math.max(payH, signH); if (v.payTo.where === "closing") closingH = Math.max(payH, signH);
else closingH = signH; else closingH = signH;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W); if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
+1 -1
View File
@@ -11,7 +11,7 @@ import type { StepResult } from "./lib/selfTestSteps";
const WATCHDOG_MS = 120_000; const WATCHDOG_MS = 120_000;
export async function maybeRunSelfTest(): Promise<void> { export async function maybeRunSelfTest(): Promise<void> {
let reportPath: string | null = null; let reportPath: string | null;
try { try {
reportPath = await invoke<string | null>("selftest_config"); reportPath = await invoke<string | null>("selftest_config");
} catch { } catch {
-1
View File
@@ -24,7 +24,6 @@ import {
TabPanels, TabPanels,
Tabs, Tabs,
Tile, Tile,
Toggle,
} from "@carbon/react"; } from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-react"; import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog"; import { confirm } from "@tauri-apps/plugin-dialog";
+1 -7
View File
@@ -48,8 +48,7 @@ import type {
Settings, Settings,
TaxType, TaxType,
} from "../lib/types"; } from "../lib/types";
import { computeLineAmount, computeTotals } from "../lib/invoice"; import { computeLineAmount } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators"; import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format"; import { addDays, formatAmount, todayIso } from "../lib/format";
import { modelFromAssets, prefsForInvoice } from "../lib/pdf"; import { modelFromAssets, prefsForInvoice } from "../lib/pdf";
@@ -256,11 +255,6 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
? "cgst_sgst" ? "cgst_sgst"
: "igst"; : "igst";
const clientGstinError = gstinFullError(clientGstin); const clientGstinError = gstinFullError(clientGstin);
const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate],
);
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]); const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
const payload = useMemo( const payload = useMemo(
+1 -1
View File
@@ -29,7 +29,7 @@ export default function SeriesSettings({
embedded?: boolean; embedded?: boolean;
}) { }) {
const toast = useToast(); const toast = useToast();
const [active, setActive] = useState<InvoiceSeries | null>(null); const [, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]); const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);