@bergetai/odoo-cli 0.12.4 → 0.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,9 @@ odoo sales orders update 11515 --set plan_id=2 --dry-run
68
68
  odoo sales lines add 11515 --lines extra.json
69
69
  odoo sales lines update 11818 --lines fix.json
70
70
  odoo sales plans list
71
+ odoo sales plans pricing list --pricelist "SEK price list" # recurring prices (sale.subscription.pricing)
72
+ odoo sales plans pricing create --pricelist 11 --product 188 --plan Yearly --price 19800 --dry-run
73
+ odoo sales plans pricing update 131 --price 19800 --dry-run
71
74
  odoo sales invoices precedent --product 188 # invoicing currency history
72
75
  odoo sales pdf 11515 --out ./quotation.pdf
73
76
 
@@ -155,7 +158,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
155
158
 
156
159
  ## Features
157
160
 
158
- - Sales surface (`sales`) — quotations and sales orders (`sale.order`) without the raw XML-RPC plumbing: `orders list` (curated columns incl. pricelist/currency, `--partner`/`--state` filters plus `--filter`/`--domain`), `orders read` (full header, partner with VAT/address, and the order lines with sections/notes), `orders create` (first-class flags for `--partner`/`--pricelist`/`--payment-terms`/`--validity`/`--opportunity`/`--salesperson`/`--plan`/`--note`, order lines as a JSON array via `--lines <file>`/`-` supporting `line_section`/`line_note` lines and product lines with `tax_ids`; missing line name/price default from the product), `orders update` (header-only `--set` with an allowlist — setting `state` is refused by design: confirm/cancel is a human step in the Odoo UI), `lines add`/`lines update` (same JSON line format), `plans list` (`sale.subscription.plan`), `invoices precedent --product` (posted customer-invoice lines for a product, newest first — invoicing currency precedent), and `pdf <id> --out <path>` (portal quotation PDF via the order's `access_token`, verified `%PDF` magic). **Sending quotations is deliberately not implemented** — it is done by a human via 'Send by Email' in the Odoo UI (gives the customer Accept & Sign), CLI policy 2026-09-24
161
+ - Sales surface (`sales`) — quotations and sales orders (`sale.order`) without the raw XML-RPC plumbing: `orders list` (curated columns incl. pricelist/currency, `--partner`/`--state` filters plus `--filter`/`--domain`), `orders read` (full header, partner with VAT/address, and the order lines with sections/notes), `orders create` (first-class flags for `--partner`/`--pricelist`/`--payment-terms`/`--validity`/`--opportunity`/`--salesperson`/`--plan`/`--note`, order lines as a JSON array via `--lines <file>`/`-` supporting `line_section`/`line_note` lines and product lines with `tax_ids`; missing line name/price default from the product), `orders update` (header-only `--set` with an allowlist — setting `state` is refused by design: confirm/cancel is a human step in the Odoo UI), `lines add`/`lines update` (same JSON line format), `plans list` (`sale.subscription.plan`), `plans pricing list`/`create`/`update` (recurring prices, `sale.subscription.pricing` — one rule per pricelist+product+plan; subscription order lines price from these rules, not from `product.pricelist.item`), `invoices precedent --product` (posted customer-invoice lines for a product, newest first — invoicing currency precedent), and `pdf <id> --out <path>` (portal quotation PDF via the order's `access_token`, verified `%PDF` magic). **Sending quotations is deliberately not implemented** — it is done by a human via 'Send by Email' in the Odoo UI (gives the customer Accept & Sign), CLI policy 2026-09-24
159
162
  - Signature pipeline (`sign`) — Odoo Sign with safety rails learned from live incidents:
160
163
  - `sign templates list/show` and `sign templates create-from-document <id>` — builds a
161
164
  `sign.template` from a Documents file, converting non-PDF files (`.docx` etc.) to PDF through
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, part
13
13
  import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
14
14
  import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
15
15
  import { signTemplatesCreateFromDocumentCommand, signTemplatesListCommand, signTemplatesShowCommand, signItemsAddCommand, signRequestsCreateCommand, signRequestsListCommand, signRequestsCancelCommand, } from "./commands/sign.js";
16
- import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, } from "./commands/sales.js";
16
+ import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, plansPricingCreateCommand, plansPricingListCommand, plansPricingUpdateCommand, } from "./commands/sales.js";
17
17
  import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
18
18
  import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
19
19
  import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
@@ -1033,6 +1033,59 @@ Examples:
1033
1033
  .action(async (opts) => {
1034
1034
  await run((g) => plansListCommand(opts, g), program);
1035
1035
  });
1036
+ const salesPlansPricing = salesPlans
1037
+ .command("pricing")
1038
+ .description("recurring prices per pricelist/product/plan (sale.subscription.pricing)");
1039
+ salesPlansPricing
1040
+ .command("list")
1041
+ .description("list recurring pricing rules")
1042
+ .option("--pricelist <name|id>", "filter by pricelist (product.pricelist)")
1043
+ .option("--product <name|id>", "filter by product (product.template)")
1044
+ .option("--plan <name|id>", "filter by plan (sale.subscription.plan)")
1045
+ .option("--limit <n>", "max records (default: 50)", "50")
1046
+ .addHelpText("after", `
1047
+ Subscription order lines price from these rules, not from
1048
+ product.pricelist.item. If an order's pricelist has no rule for the order's
1049
+ plan, the line silently prices at 0 — check here before switching pricelists.
1050
+
1051
+ Examples:
1052
+ $ odoo sales plans pricing list --pricelist "SEK price list"
1053
+ $ odoo sales plans pricing list --product "Berget Code Seat" --json`)
1054
+ .action(async (opts) => {
1055
+ await run((g) => plansPricingListCommand(opts, g), program);
1056
+ });
1057
+ salesPlansPricing
1058
+ .command("create")
1059
+ .description("create a recurring pricing rule (preview with --dry-run)")
1060
+ .requiredOption("--pricelist <name|id>", "pricelist (product.pricelist)")
1061
+ .requiredOption("--product <name|id>", "product (product.template)")
1062
+ .requiredOption("--plan <name|id>", "recurring plan (sale.subscription.plan)")
1063
+ .requiredOption("--price <amount>", "recurring price (number)")
1064
+ .addHelpText("after", `
1065
+ Only one rule per (pricelist, product, plan) is allowed — duplicates are
1066
+ rejected; use 'sales plans pricing update' to change an existing rule.
1067
+
1068
+ Examples:
1069
+ $ odoo sales plans pricing create --pricelist "SEK price list" --product 188 --plan Yearly --price 19800 --dry-run
1070
+ $ odoo sales plans pricing create --pricelist 11 --product 188 --plan 2 --price 19800`)
1071
+ .action(async (opts) => {
1072
+ await run((g) => plansPricingCreateCommand(opts, g), program);
1073
+ });
1074
+ salesPlansPricing
1075
+ .command("update")
1076
+ .description("update a recurring pricing rule (preview with --dry-run)")
1077
+ .argument("<id>", "sale.subscription.pricing id")
1078
+ .option("--pricelist <name|id>", "pricelist (product.pricelist)")
1079
+ .option("--product <name|id>", "product (product.template)")
1080
+ .option("--plan <name|id>", "recurring plan (sale.subscription.plan)")
1081
+ .option("--price <amount>", "recurring price (number)")
1082
+ .addHelpText("after", `
1083
+ Examples:
1084
+ $ odoo sales plans pricing update 131 --price 19800 --dry-run
1085
+ $ odoo sales plans pricing update 131 --plan Yearly`)
1086
+ .action(async (id, opts) => {
1087
+ await run((g) => plansPricingUpdateCommand(id, opts, g), program);
1088
+ });
1036
1089
  const salesInvoices = sales
1037
1090
  .command("invoices")
1038
1091
  .description("invoicing reports (account.move.line)");
@@ -15,7 +15,8 @@ import { resolveConfig } from "../config.js";
15
15
  import { conflictError, networkError, notFoundError, usageError, } from "../errors.js";
16
16
  import { buildFilterClauses, parseDomain, parseFilterScalar } from "../filter.js";
17
17
  import { ModelFields, resolveReference } from "../model.js";
18
- import { parseDate } from "../mutation.js";
18
+ import { parseDate, parseDecimal } from "../mutation.js";
19
+ import { XmlRpcFaultError } from "../xmlrpc.js";
19
20
  import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
20
21
  import { authenticatedClient, parseId, } from "../resource.js";
21
22
  import { formatKeyValues, formatTable, humanCell } from "../table.js";
@@ -25,6 +26,7 @@ import { formatKeyValues, formatTable, humanCell } from "../table.js";
25
26
  const SALE_ORDER = "sale.order";
26
27
  const SALE_ORDER_LINE = "sale.order.line";
27
28
  const SUBSCRIPTION_PLAN = "sale.subscription.plan";
29
+ const SUBSCRIPTION_PRICING = "sale.subscription.pricing";
28
30
  const STATE_LABELS = {
29
31
  draft: "Quotation",
30
32
  sent: "Quotation Sent",
@@ -724,6 +726,232 @@ export async function plansListCommand(opts, globals) {
724
726
  ]);
725
727
  process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
726
728
  }
729
+ // ---------------------------------------------------------------------------
730
+ // plans pricing — recurring prices per pricelist/product/plan
731
+ // (sale.subscription.pricing). Subscription order lines price from these
732
+ // rules, NOT from product.pricelist.item — an order whose pricelist lacks a
733
+ // pricing rule for the plan on the order silently prices the line at 0.
734
+ // ---------------------------------------------------------------------------
735
+ const PRICING_FIELDS = [
736
+ "id",
737
+ "pricelist_id",
738
+ "product_template_id",
739
+ "plan_id",
740
+ "price",
741
+ ];
742
+ async function resolvePricingRefs(client, opts, globals) {
743
+ const refs = {};
744
+ if (opts.pricelist !== undefined) {
745
+ refs.pricelist = await resolveReference(client, "product.pricelist", opts.pricelist, { label: "pricelist" });
746
+ logVerbose(`resolved --pricelist "${opts.pricelist}" -> [${refs.pricelist.id}, "${refs.pricelist.name}"]`, globals.verbose);
747
+ }
748
+ if (opts.product !== undefined) {
749
+ refs.product = await resolveReference(client, "product.template", opts.product, { label: "product template" });
750
+ logVerbose(`resolved --product "${opts.product}" -> [${refs.product.id}, "${refs.product.name}"]`, globals.verbose);
751
+ }
752
+ if (opts.plan !== undefined) {
753
+ refs.plan = await resolveReference(client, SUBSCRIPTION_PLAN, opts.plan, {
754
+ label: "plan",
755
+ });
756
+ logVerbose(`resolved --plan "${opts.plan}" -> [${refs.plan.id}, "${refs.plan.name}"]`, globals.verbose);
757
+ }
758
+ return refs;
759
+ }
760
+ export async function plansPricingListCommand(opts, globals) {
761
+ const client = await authenticatedClient(globals);
762
+ await ModelFields.load(client, SUBSCRIPTION_PRICING);
763
+ const limit = parseCount(opts.limit, "--limit");
764
+ const refs = await resolvePricingRefs(client, opts, globals);
765
+ const domain = [];
766
+ if (refs.pricelist)
767
+ domain.push(["pricelist_id", "=", refs.pricelist.id]);
768
+ if (refs.product)
769
+ domain.push(["product_template_id", "=", refs.product.id]);
770
+ if (refs.plan)
771
+ domain.push(["plan_id", "=", refs.plan.id]);
772
+ const records = (await client.executeKw(SUBSCRIPTION_PRICING, "search_read", [
773
+ domain,
774
+ ], {
775
+ fields: PRICING_FIELDS,
776
+ limit,
777
+ order: "id asc",
778
+ }));
779
+ if (globals.json) {
780
+ printJson({ model: SUBSCRIPTION_PRICING, records, count: records.length });
781
+ return;
782
+ }
783
+ const headers = ["ID", "Pricelist", "Product", "Plan", "Price"];
784
+ const rows = records.map((r) => [
785
+ String(r.id),
786
+ humanCell(r.pricelist_id),
787
+ humanCell(r.product_template_id),
788
+ humanCell(r.plan_id),
789
+ humanCell(r.price),
790
+ ]);
791
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
792
+ }
793
+ export async function plansPricingCreateCommand(opts, globals) {
794
+ const client = await authenticatedClient(globals);
795
+ await ModelFields.load(client, SUBSCRIPTION_PRICING);
796
+ const refs = await resolvePricingRefs(client, opts, globals);
797
+ const price = parseDecimal(opts.price, "--price");
798
+ // Guardrail: one rule per (pricelist, product, plan) — a duplicate would
799
+ // make line pricing ambiguous.
800
+ const duplicates = (await client.executeKw(SUBSCRIPTION_PRICING, "search_read", [
801
+ [
802
+ ["pricelist_id", "=", refs.pricelist.id],
803
+ ["product_template_id", "=", refs.product.id],
804
+ ["plan_id", "=", refs.plan.id],
805
+ ],
806
+ ], { fields: ["id", "price"], limit: 5 }));
807
+ if (duplicates.length > 0) {
808
+ throw conflictError("duplicate_pricing", `a pricing rule already exists for pricelist ${refs.pricelist.id}, product ${refs.product.id}, plan ${refs.plan.id} (id ${duplicates[0].id}, price ${duplicates[0].price})`, `use 'sales plans pricing update ${duplicates[0].id}' to change it`);
809
+ }
810
+ const values = {
811
+ pricelist_id: refs.pricelist.id,
812
+ product_template_id: refs.product.id,
813
+ plan_id: refs.plan.id,
814
+ price,
815
+ };
816
+ if (globals.dryRun) {
817
+ if (globals.json) {
818
+ printJson({
819
+ model: SUBSCRIPTION_PRICING,
820
+ action: "create",
821
+ dry_run: true,
822
+ values,
823
+ });
824
+ return;
825
+ }
826
+ const lines = [
827
+ `Would create ${SUBSCRIPTION_PRICING}:`,
828
+ ` pricelist_id: [${refs.pricelist.id}, ${JSON.stringify(refs.pricelist.name)}]`,
829
+ ` product_template_id: [${refs.product.id}, ${JSON.stringify(refs.product.name)}]`,
830
+ ` plan_id: [${refs.plan.id}, ${JSON.stringify(refs.plan.name)}]`,
831
+ ` price: ${price}`,
832
+ ];
833
+ process.stdout.write(lines.join("\n") + "\n");
834
+ return;
835
+ }
836
+ let id;
837
+ try {
838
+ id = await client.executeKw(SUBSCRIPTION_PRICING, "create", [values]);
839
+ }
840
+ catch (error) {
841
+ if (!(error instanceof XmlRpcFaultError) &&
842
+ error.exitCode === 6) {
843
+ throw networkError(error.message, "the request reached the server; the record may have been created — verify with list before retrying");
844
+ }
845
+ if (error instanceof XmlRpcFaultError) {
846
+ throw conflictError("server_fault", `the server rejected the create (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
847
+ }
848
+ throw error;
849
+ }
850
+ if (typeof id !== "number" || !Number.isInteger(id)) {
851
+ throw networkError(`unexpected create() response from the server: ${JSON.stringify(id)}`);
852
+ }
853
+ if (globals.json) {
854
+ printJson({
855
+ model: SUBSCRIPTION_PRICING,
856
+ action: "create",
857
+ dry_run: false,
858
+ id,
859
+ });
860
+ return;
861
+ }
862
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
863
+ const line = `Created ${SUBSCRIPTION_PRICING} ${id}`;
864
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
865
+ }
866
+ export async function plansPricingUpdateCommand(idArg, opts, globals) {
867
+ const pricingId = parseId(idArg);
868
+ const client = await authenticatedClient(globals);
869
+ await ModelFields.load(client, SUBSCRIPTION_PRICING);
870
+ const refs = await resolvePricingRefs(client, opts, globals);
871
+ const values = {};
872
+ if (refs.pricelist)
873
+ values.pricelist_id = refs.pricelist.id;
874
+ if (refs.product)
875
+ values.product_template_id = refs.product.id;
876
+ if (refs.plan)
877
+ values.plan_id = refs.plan.id;
878
+ if (opts.price !== undefined)
879
+ values.price = parseDecimal(opts.price, "--price");
880
+ if (Object.keys(values).length === 0) {
881
+ throw usageError("nothing to update", "pass at least one of --pricelist, --product, --plan, --price");
882
+ }
883
+ // Read-first pass: existence check + diff, mirroring orders update.
884
+ const rows = (await client.executeKw(SUBSCRIPTION_PRICING, "read", [
885
+ [pricingId],
886
+ ], { fields: PRICING_FIELDS }));
887
+ if (rows.length === 0) {
888
+ throw notFoundError("record_not_found", `no ${SUBSCRIPTION_PRICING} record with id ${pricingId}`);
889
+ }
890
+ const rec = rows[0];
891
+ const changes = {};
892
+ for (const [field, after] of Object.entries(values)) {
893
+ const before = rec[field];
894
+ const beforeId = Array.isArray(before) ? before[0] : before;
895
+ if (beforeId !== after) {
896
+ changes[field] = { before, after };
897
+ }
898
+ }
899
+ if (globals.dryRun) {
900
+ if (globals.json) {
901
+ printJson({
902
+ model: SUBSCRIPTION_PRICING,
903
+ action: "update",
904
+ dry_run: true,
905
+ changes: { [String(pricingId)]: changes },
906
+ });
907
+ return;
908
+ }
909
+ const lines = [];
910
+ if (Object.keys(changes).length === 0) {
911
+ lines.push(`Unchanged ${SUBSCRIPTION_PRICING} ${pricingId} — nothing to change`);
912
+ }
913
+ else {
914
+ lines.push(`Would update ${SUBSCRIPTION_PRICING} ${pricingId}:`);
915
+ for (const [field, change] of Object.entries(changes)) {
916
+ lines.push(` ${field}: ${humanCell(change.before)} -> ${humanCell(change.after)}`);
917
+ }
918
+ }
919
+ process.stdout.write(lines.join("\n") + "\n");
920
+ return;
921
+ }
922
+ if (Object.keys(changes).length > 0) {
923
+ try {
924
+ await client.executeKw(SUBSCRIPTION_PRICING, "write", [
925
+ [pricingId],
926
+ values,
927
+ ]);
928
+ }
929
+ catch (error) {
930
+ if (error instanceof XmlRpcFaultError) {
931
+ throw conflictError("server_fault", `the server rejected the update (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
932
+ }
933
+ throw error;
934
+ }
935
+ }
936
+ if (globals.json) {
937
+ printJson({
938
+ model: SUBSCRIPTION_PRICING,
939
+ action: "update",
940
+ dry_run: false,
941
+ changes: { [String(pricingId)]: changes },
942
+ });
943
+ return;
944
+ }
945
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
946
+ const line = Object.keys(changes).length === 0
947
+ ? `Unchanged ${SUBSCRIPTION_PRICING} ${pricingId}`
948
+ : `Updated ${SUBSCRIPTION_PRICING} ${pricingId}`;
949
+ process.stdout.write((Object.keys(changes).length === 0
950
+ ? line
951
+ : color
952
+ ? `\x1b[33m${line}\x1b[0m`
953
+ : line) + "\n");
954
+ }
727
955
  export async function invoicesPrecedentCommand(opts, globals) {
728
956
  const client = await authenticatedClient(globals);
729
957
  if (!opts.product) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"