@raisenow/tamaro-cli 3.1.0-dev.0 → 3.1.0-dev.1

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.
Files changed (3) hide show
  1. package/README.md +10 -1
  2. package/dist/cli.js +121 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -282,7 +282,16 @@ pnpx @raisenow/tamaro-cli update-epms --tag WEB-123
282
282
 
283
283
  ## `validate`
284
284
 
285
- Validate the current configuration and fail on known issues such as invalid email templates.
285
+ Validate the current configuration and fail on known issues such as invalid
286
+ email templates, invalid `.env` values, or RaiseNow-generated tax receipts
287
+ enabled without an uploaded organisation template.
288
+
289
+ When `autogeneratedDonationReceiptEnabled` (or
290
+ `autogenerated_donation_receipt_enabled`) is `true`, this command authenticates
291
+ with EPMS (browser login or client credentials) and checks that a donation
292
+ tax-receipt PDF template exists for each configured account. See the
293
+ [Confluence guide](https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Tax-receipt-validation--how-CS-resolves-it)
294
+ for how to resolve a failed check.
286
295
 
287
296
  ### Example
288
297
 
package/dist/cli.js CHANGED
@@ -12,9 +12,9 @@ import ky from "ky";
12
12
  import { createServer } from "node:http";
13
13
  import open from "open";
14
14
  import { globSync } from "glob";
15
+ import { codeFrameColumns } from "@babel/code-frame";
15
16
  import Handlebars from "handlebars";
16
17
  import helpers from "handlebars-helpers";
17
- import { codeFrameColumns } from "@babel/code-frame";
18
18
  import { config, parse } from "dotenv";
19
19
  import { getPortPromise } from "portfinder";
20
20
  import prompts from "prompts";
@@ -280,6 +280,8 @@ var EpmsClient = class EpmsClient {
280
280
  #cacheKey;
281
281
  #epmsClient;
282
282
  #authType;
283
+ /** In-memory account UUID → organisation UUID cache for this client instance. */
284
+ #organisationIdByAccountUuid = /* @__PURE__ */ new Map();
283
285
  /**
284
286
  * Creates an instance of EpmsClient.
285
287
  *
@@ -308,14 +310,25 @@ var EpmsClient = class EpmsClient {
308
310
  return this.#epmsClient.get("users/myself").json().then((response) => myselfSchema.parse(response));
309
311
  }
310
312
  async getOrganisationIdByAccountUuid(accountUuid) {
313
+ const cached = this.#organisationIdByAccountUuid.get(accountUuid);
314
+ if (cached) return cached;
311
315
  try {
312
316
  const organisationUuid = (await this.#epmsClient.get(`accounts/${accountUuid}`).json()).organisation.uuid;
313
317
  if (!organisationUuid) throw new Error("Organisation UUID not found in response");
318
+ this.#organisationIdByAccountUuid.set(accountUuid, organisationUuid);
314
319
  return organisationUuid;
315
320
  } catch (error) {
316
321
  throw new Error(`Organisation UUID not found for account UUID: ${accountUuid}`, { cause: error });
317
322
  }
318
323
  }
324
+ async listOrganisationFiles(organisationUuid) {
325
+ try {
326
+ const response = await this.#epmsClient.get(`organisations/${organisationUuid}/files`).json();
327
+ return Array.isArray(response) ? response : response.data ?? [];
328
+ } catch (error) {
329
+ throw new Error(`Failed to list organisation files for organisation UUID: ${organisationUuid}`, { cause: error });
330
+ }
331
+ }
319
332
  async updateTamaro(configName, tag, json) {
320
333
  return this.#epmsClient.put(`products/tamaro/${configName}/tags/${tag}`, { json }).json();
321
334
  }
@@ -443,6 +456,99 @@ const assertArchiveTargetDoesNotExist = (archiveTarget) => {
443
456
  if (fs.existsSync(archiveTarget)) halt(`Archive target already exists: ${archiveTarget}. Please remove it first.`);
444
457
  };
445
458
  //#endregion
459
+ //#region src/lib/validators/validateDonationReceiptTemplates.ts
460
+ /** Internal docs validation guide for CS */
461
+ const DONATION_RECEIPT_VALIDATION_GUIDE_URL = "https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Donation-tax-receipts";
462
+ const FLAG_KEYS = ["autogeneratedDonationReceiptEnabled", "autogenerated_donation_receipt_enabled"];
463
+ /**
464
+ * Resolve which config.yml key enabled RaiseNow-generated donation tax
465
+ * receipts. Supports both camelCase and snake_case keys used in customer
466
+ * configs; returns undefined when the option is not strictly `true`.
467
+ */
468
+ const isAutogeneratedDonationReceiptEnabled = (rawConfig) => {
469
+ if (!rawConfig || typeof rawConfig !== "object") return;
470
+ const config = rawConfig;
471
+ for (const key of FLAG_KEYS) if (config[key] === true) return key;
472
+ };
473
+ /**
474
+ * Check that a donation tax receipt PDF template exists for the account in
475
+ * the organisation files list. Matches Hub storage layout:
476
+ * `{accountUuid}/donation-receipt-templates/{language}.pdf`
477
+ */
478
+ const donationReceiptTemplateExists = (files, accountUuid) => {
479
+ const prefix = `${accountUuid}/donation-receipt-templates/`;
480
+ return files.some((file) => file.name.startsWith(prefix) && file.name.endsWith(".pdf") && file.name.slice(prefix.length).length > 0 && !file.name.slice(prefix.length).includes("/"));
481
+ };
482
+ /**
483
+ * Locate the line of a top-level `key:` assignment in the config.yml source,
484
+ * so the error can be rendered as a code frame pointing at the value.
485
+ */
486
+ const findKeyLocation$1 = (source, key) => {
487
+ const lines = source.split("\n");
488
+ const assignment = new RegExp(String.raw`^\s*${key}\s*:`);
489
+ const index = lines.findIndex((line) => assignment.test(line));
490
+ if (index === -1) return;
491
+ const line = index + 1;
492
+ const valueStart = lines[index].indexOf(":") + 1;
493
+ return {
494
+ end: {
495
+ column: lines[index].length,
496
+ line
497
+ },
498
+ start: {
499
+ column: valueStart + 1,
500
+ line
501
+ }
502
+ };
503
+ };
504
+ /**
505
+ * Format an error for a `key:` assignment as a code frame pointing at the
506
+ * value, so it looks fancy like this:
507
+ *
508
+ * config.yml:51
509
+ * 50 | show_stored_customer_donation_receipt: true
510
+ * 51 | autogeneratedDonationReceiptEnabled: true
511
+ * | ^^^^ No donation tax receipt PDF template found…
512
+ */
513
+ const formatError$1 = (configYmlPath, source, key, message) => {
514
+ const location = findKeyLocation$1(source, key);
515
+ if (!location) return `${configYmlPath}: ${key}: ${message}`;
516
+ const frame = codeFrameColumns(source, location, {
517
+ forceColor: true,
518
+ highlightCode: true,
519
+ message
520
+ });
521
+ return `\n${configYmlPath}:${location.start.line}\n${frame}`;
522
+ };
523
+ /**
524
+ * Validate that RaiseNow-generated tax receipts are only enabled when each
525
+ * configured EPMS account has a donation tax receipt PDF template uploaded.
526
+ */
527
+ const validateDonationReceiptTemplates = async (configYmlPath) => {
528
+ if (!fs.existsSync(configYmlPath)) return [];
529
+ const source = fs.readFileSync(configYmlPath, "utf8");
530
+ const flagKey = isAutogeneratedDonationReceiptEnabled(yaml.load(source));
531
+ if (!flagKey) return [];
532
+ const { accountUuidProd, accountUuidStage } = loadTamaroMetadataFromConfig(configYmlPath);
533
+ const errors = [];
534
+ const environments = [{
535
+ accountUuid: accountUuidStage,
536
+ env: "stage",
537
+ label: "Stage"
538
+ }, {
539
+ accountUuid: accountUuidProd,
540
+ env: "prod",
541
+ label: "Prod"
542
+ }];
543
+ for (const { accountUuid, env, label } of environments) {
544
+ if (!accountUuid) continue;
545
+ const epmsClient = epmsAuthenticate(env);
546
+ const organisationUuid = await epmsClient.getOrganisationIdByAccountUuid(accountUuid);
547
+ if (!donationReceiptTemplateExists(await epmsClient.listOrganisationFiles(organisationUuid), accountUuid)) errors.push(formatError$1(configYmlPath, source, flagKey, `No donation tax receipt PDF template found for the ${label} account (${accountUuid}). See: ${DONATION_RECEIPT_VALIDATION_GUIDE_URL}`));
548
+ }
549
+ return errors;
550
+ };
551
+ //#endregion
446
552
  //#region src/lib/validators/validateEmailTemplates.ts
447
553
  /**
448
554
  * Register custom Handlebars helpers - the same, which are used in the Email Service.
@@ -564,21 +670,16 @@ const TAMARO_CORE_REGISTRY_URL = "https://registry.npmjs.org/@raisenow/tamaro-co
564
670
  * Check that the configured version exists for "@raisenow/tamaro-core" on npm:
565
671
  * as a dist-tag (e.g. "latest", "lts_2026"), an exact version, or a partial
566
672
  * version ("2", "2.15") matching at least one published version.
567
- *
568
- * Returns undefined when the registry is unreachable, so offline builds are
569
- * not blocked by this check.
570
673
  */
571
674
  const coreVersionExists = async (version) => {
572
- try {
573
- const response = await fetch(TAMARO_CORE_REGISTRY_URL, { headers: { accept: "application/vnd.npm.install-v1+json" } });
574
- if (!response.ok) return;
575
- const metadata = await response.json();
576
- const distTags = Object.keys(metadata["dist-tags"] ?? {});
577
- const versions = Object.keys(metadata.versions ?? {});
578
- return distTags.includes(version) || versions.includes(version) || versions.some((existing) => existing.startsWith(`${version}.`));
579
- } catch {
580
- return;
581
- }
675
+ const metadata = await ky.get(TAMARO_CORE_REGISTRY_URL, {
676
+ headers: { accept: "application/vnd.npm.install-v1+json" },
677
+ retry: 0,
678
+ timeout: DEFAULT_HTTP_TIMEOUT
679
+ }).json();
680
+ const distTags = Object.keys(metadata["dist-tags"] ?? {});
681
+ const versions = Object.keys(metadata.versions ?? {});
682
+ return distTags.includes(version) || versions.includes(version) || versions.some((existing) => existing.startsWith(`${version}.`));
582
683
  };
583
684
  /**
584
685
  * Locate the line of a `KEY=` assignment in the .env source, so the error can
@@ -652,7 +753,11 @@ const validateEnv = async (dotEnvPath) => {
652
753
  * deployed.
653
754
  */
654
755
  const validate = async () => {
655
- const errors = [...await validateEnv(".env"), ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted())];
756
+ const errors = [
757
+ ...await validateEnv(".env"),
758
+ ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted()),
759
+ ...await validateDonationReceiptTemplates("config.yml")
760
+ ];
656
761
  if (errors.length > 0) {
657
762
  logError("❌ Validation failed with the following errors:");
658
763
  console.log(errors.map((el) => el.toString()).join("\n"));
@@ -1329,7 +1434,7 @@ const promptBucketTamaroEmailConfig = async () => {
1329
1434
  //#endregion
1330
1435
  //#region package.json
1331
1436
  var name = "@raisenow/tamaro-cli";
1332
- var version = "3.1.0-dev.0";
1437
+ var version = "3.1.0-dev.1";
1333
1438
  //#endregion
1334
1439
  //#region src/cli.ts
1335
1440
  const cli = createCommand().name(name).description("CLI for Tamaro Customer Configurations development").version(version, "-v, --version");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raisenow/tamaro-cli",
3
- "version": "3.1.0-dev.0",
3
+ "version": "3.1.0-dev.1",
4
4
  "author": {
5
5
  "name": "RaiseNow",
6
6
  "email": "development@raisenow.com"