@fluid-app/fluid-cli-localization 0.1.5 → 0.1.7

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/dist/index.mjs CHANGED
@@ -628,6 +628,433 @@ function complianceView(countryCode, atlas) {
628
628
  };
629
629
  }
630
630
  //#endregion
631
+ //#region src/countries/forms.ts
632
+ const FORMS_PATH = "/api/forms";
633
+ const PER_PAGE$2 = 100;
634
+ const MAX_PAGES$3 = 200;
635
+ /** The title of the enrollment form the atlas seeds for a country, the same
636
+ * title the admin gives it. */
637
+ const enrollmentFormTitle = (countryName) => `Enrollment - ${countryName}`;
638
+ /** The member types an atlas field can be required for. */
639
+ const AUDIENCES = [
640
+ "rep",
641
+ "preferred",
642
+ "customer"
643
+ ];
644
+ /**
645
+ * Each atlas `component` (a form-builder component name) and the element it
646
+ * is stored as. Identity fields are `input` elements named by `puck_type`.
647
+ */
648
+ const COMPONENT_MAPPINGS = {
649
+ LegalName: {
650
+ element_type: "input",
651
+ puck_type: "legal_name",
652
+ text: true
653
+ },
654
+ FullName: {
655
+ element_type: "input",
656
+ puck_type: "full_name",
657
+ text: true
658
+ },
659
+ Email: {
660
+ element_type: "input",
661
+ puck_type: "email",
662
+ text: true
663
+ },
664
+ PhoneNumber: {
665
+ element_type: "input",
666
+ puck_type: "phone_number",
667
+ text: true
668
+ },
669
+ DateOfBirth: {
670
+ element_type: "input",
671
+ puck_type: "date_of_birth",
672
+ sensitive: true
673
+ },
674
+ ShortTextEntry: {
675
+ element_type: "short_text_entry",
676
+ puck_type: "short_text_entry",
677
+ text: true
678
+ },
679
+ LongTextEntry: {
680
+ element_type: "long_text_entry",
681
+ puck_type: "long_text_entry",
682
+ text: true
683
+ },
684
+ NumberEntry: {
685
+ element_type: "number_entry",
686
+ puck_type: "number_entry",
687
+ text: true
688
+ },
689
+ YesNo: {
690
+ element_type: "yes_no_entry",
691
+ puck_type: "yes_no_entry"
692
+ },
693
+ MultipleChoice: {
694
+ element_type: "multiple_choice_entry",
695
+ puck_type: "multiple_choice_entry"
696
+ },
697
+ FileUpload: {
698
+ element_type: "file_upload",
699
+ puck_type: "file_upload"
700
+ },
701
+ Checkbox: {
702
+ element_type: "checkbox",
703
+ puck_type: "checkbox"
704
+ }
705
+ };
706
+ /** Components an enrollment form must not collect. The repository's review
707
+ * rules treat a form that collects SSNs as critical, so the admin never
708
+ * seeds one either. */
709
+ const EXCLUDED_COMPONENTS = new Set(["SocialSecurityNumber"]);
710
+ /** Identity components: a form with any one of them covers the atlas field,
711
+ * whatever its label. Other components match by label. */
712
+ const IDENTITY_PUCK_TYPES = new Set([
713
+ "legal_name",
714
+ "full_name",
715
+ "email",
716
+ "phone_number",
717
+ "date_of_birth",
718
+ "ssn"
719
+ ]);
720
+ /** The element types an existing component may be stored as, older names
721
+ * included, each mapped to the `puck_type` this module compares. */
722
+ const STORED_TYPE_ALIASES = {
723
+ legal_name: "legal_name",
724
+ full_name: "full_name",
725
+ email: "email",
726
+ phone_number: "phone_number",
727
+ date_of_birth: "date_of_birth",
728
+ ssn: "ssn",
729
+ short_text_entry: "short_text_entry",
730
+ text: "short_text_entry",
731
+ long_text_entry: "long_text_entry",
732
+ number_entry: "number_entry",
733
+ number: "number_entry",
734
+ yes_no_entry: "yes_no_entry",
735
+ yes_no: "yes_no_entry",
736
+ multiple_choice_entry: "multiple_choice_entry",
737
+ multiple_choice: "multiple_choice_entry",
738
+ file_upload: "file_upload",
739
+ checkbox: "checkbox"
740
+ };
741
+ /**
742
+ * Labels of data an enrollment form must not collect: SSNs, national and tax
743
+ * ID numbers, bank and card details, and passwords. The repository's review
744
+ * rules treat a form that collects any of them as critical, and the atlas
745
+ * names them in the label (a `ShortTextEntry` labelled "CPF" or "IBAN"), not
746
+ * in the component, so the label is checked too, on every component that
747
+ * takes a value: a respondent types what the label asks for, whatever the
748
+ * element type. A false match only leaves a field to a person.
749
+ */
750
+ const RESTRICTED_DATA_LABEL = /\b(SSN|EIN|ITIN|CPF|CNPJ|CURP|RFC|RUC|RUT|NIT|NIF|NIE|NIP|TIN|TPIN|PAN|INN|OIB|CUIT|CUIL|NPWP|IRD|UTR|KRA|IBAN|SWIFT|BIC|CVV|CVC|VAT|GST|GSTIN|ABN|TRN|NTN)\b|tax|fiscal|steuer|social security|national (id|identity|insurance)|identity (number|card|document)|government id|passport|personnummer|id number|bank|routing|sort code|account number|e-?wallet|card number|credit card|debit card|password|passcode/i;
751
+ /** Components whose answer is yes or no, so they collect no number
752
+ * whatever their label says: a yes/no about tax registration is fine. Every
753
+ * other component takes a value, and its label decides. */
754
+ const BOOLEAN_COMPONENTS = new Set(["YesNo", "Checkbox"]);
755
+ /** Whether an atlas field asks for data an enrollment form must not
756
+ * collect, by component or by label. */
757
+ function collectsRestrictedData(field) {
758
+ return EXCLUDED_COMPONENTS.has(field.component) || !BOOLEAN_COMPONENTS.has(field.component) && RESTRICTED_DATA_LABEL.test(field.label ?? "");
759
+ }
760
+ /** The choices of a MultipleChoice field: strings or `{ label }`. */
761
+ function choicesOf(field) {
762
+ const options = field["options"];
763
+ if (!Array.isArray(options)) return [];
764
+ return options.flatMap((choice) => {
765
+ const label = typeof choice === "string" ? choice : choice?.label;
766
+ return typeof label === "string" && label.trim() ? [label.trim()] : [];
767
+ });
768
+ }
769
+ /** Whether every member type gets the field. A Post Purchase form is chosen
770
+ * by country only, so a field for some member types would reach everyone. */
771
+ function forEveryone(field) {
772
+ const audiences = field.requiredFor ?? [];
773
+ return audiences.length === 0 || AUDIENCES.every((a) => audiences.includes(a));
774
+ }
775
+ /** Each atlas enrollment field, in atlas order, with the element it maps to
776
+ * and whether the command may create it. */
777
+ function mapAtlasFields(fields) {
778
+ return fields.map((field, index) => ({
779
+ field,
780
+ index
781
+ })).filter(({ field }) => Boolean(field.label?.trim())).sort((a, b) => {
782
+ const oa = a.field.order ?? Number.POSITIVE_INFINITY;
783
+ const ob = b.field.order ?? Number.POSITIVE_INFINITY;
784
+ return oa === ob ? a.index - b.index : oa - ob;
785
+ }).map(({ field, index }) => {
786
+ const mapping = COMPONENT_MAPPINGS[field.component];
787
+ const options = field.component === "MultipleChoice" ? choicesOf(field) : [];
788
+ const excluded = collectsRestrictedData(field) ? "notCollected" : !forEveryone(field) ? "someMemberTypes" : !mapping || field.component === "MultipleChoice" && options.length === 0 ? "needsFormBuilder" : null;
789
+ return {
790
+ id: field.id ?? `field-${index}`,
791
+ label: field.label.trim(),
792
+ component: field.component,
793
+ required: field.required === true,
794
+ order: field.order ?? index,
795
+ requiredFor: field.requiredFor ?? [],
796
+ elementType: mapping?.element_type ?? null,
797
+ puckType: mapping?.puck_type ?? null,
798
+ sensitive: mapping?.sensitive === true,
799
+ excluded,
800
+ options
801
+ };
802
+ });
803
+ }
804
+ const EXCLUSION_REASONS = {
805
+ notCollected: "Enrollment forms must not collect SSNs, national or tax ID numbers, bank or card details, or passwords. Collect it outside Fluid's forms, e.g. through your tax or payout provider.",
806
+ someMemberTypes: "The atlas asks only some member types for it, and a Post Purchase form reaches everyone who enrolls in the country. Collect it where you can limit it to those member types.",
807
+ needsFormBuilder: "The atlas doesn't give enough to build it (a multiple choice with no choices, or a component the form builder doesn't have). Add it in the form builder."
808
+ };
809
+ /** The component for an atlas field, as the form builder would save it. */
810
+ function componentFor(field) {
811
+ const mapping = COMPONENT_MAPPINGS[field.component];
812
+ if (!mapping || field.excluded) throw new LocalizationError(`Atlas field ${field.id} (${field.component}) isn't one this command creates.`);
813
+ return {
814
+ element_type: mapping.element_type,
815
+ puck_type: mapping.puck_type,
816
+ label: field.label,
817
+ placeholder: "",
818
+ field_data_value: "",
819
+ required: field.required,
820
+ ...mapping.text ? { case_sensitive: false } : {},
821
+ sensitive_info: field.sensitive,
822
+ auto_translate: true,
823
+ translations: [],
824
+ show_label: true,
825
+ is_form_field: true,
826
+ ...field.component === "MultipleChoice" ? { multiple_choice_options: field.options.map((label, index) => ({
827
+ label,
828
+ id: String(index)
829
+ })) } : {},
830
+ ...field.component === "YesNo" ? { yes_no_options: [{
831
+ label: "Yes",
832
+ value: "yes"
833
+ }, {
834
+ label: "No",
835
+ value: "no"
836
+ }] } : {}
837
+ };
838
+ }
839
+ const normalizeLabel = (label) => typeof label === "string" ? label.trim().replace(/\s+/g, " ").toLowerCase() : "";
840
+ /** The `puck_type` an existing component compares as, older types included. */
841
+ function storedType(component) {
842
+ return STORED_TYPE_ALIASES[(typeof component.puck_type === "string" && component.puck_type ? component.puck_type : component.element_type ?? "").toLowerCase()] ?? null;
843
+ }
844
+ /** Whether a component already collects an atlas field: the same identity
845
+ * field, or the same kind of field with the same label. */
846
+ function componentCovers(component, field) {
847
+ if (!field.puckType) return false;
848
+ const type = storedType(component);
849
+ if (type !== field.puckType) return false;
850
+ if (IDENTITY_PUCK_TYPES.has(type)) return true;
851
+ return normalizeLabel(component.label) === normalizeLabel(field.label);
852
+ }
853
+ /** A form's components, parsed. Ids are the stored elements' ids. */
854
+ function componentsOf(form) {
855
+ const raw = form.form_components;
856
+ if (raw == null || raw === "") return [];
857
+ let parsed;
858
+ try {
859
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
860
+ } catch {
861
+ throw new LocalizationError(`Form ${form.id} ("${form.title}") has components that aren't valid JSON, so it can't be read safely. Fix it in the form builder.`);
862
+ }
863
+ return Array.isArray(parsed) ? parsed.filter((c) => c && typeof c === "object") : [];
864
+ }
865
+ /** GET /api/forms, every page. */
866
+ async function listForms(client) {
867
+ const forms = [];
868
+ for (let page = 1; page <= MAX_PAGES$3; page += 1) {
869
+ const body = await client.get(FORMS_PATH, {
870
+ page,
871
+ per_page: PER_PAGE$2
872
+ });
873
+ const batch = body.data ?? [];
874
+ forms.push(...batch);
875
+ const next = body.pagination?.next_page;
876
+ const total = body.pagination?.total_pages ?? 1;
877
+ if (batch.length === 0 || next == null && page >= total) return forms;
878
+ }
879
+ throw new LocalizationError(`The company has more than ${MAX_PAGES$3 * PER_PAGE$2} forms, more than one run reads.`);
880
+ }
881
+ /** GET /api/forms/:id, read again just before it's changed. */
882
+ async function getForm(client, id) {
883
+ const body = await client.get(`${FORMS_PATH}/${id}`);
884
+ const form = "form" in body && body.form ? body.form : body;
885
+ if (typeof form?.id !== "number") throw new LocalizationError(`Form ${id} couldn't be read.`);
886
+ return form;
887
+ }
888
+ const isPostPurchase = (form) => form.form_type === "post_purchase";
889
+ const isActive = (form) => form.status === "active";
890
+ const countryIdsOf = (form) => (form.countries ?? []).map((c) => c.id);
891
+ /**
892
+ * The form --create writes to: the form named with --form, else the
893
+ * country's atlas form (titled `Enrollment - <Country>`, Post Purchase,
894
+ * scoped to this country alone), active before draft. A form shared with
895
+ * other countries is never a target: a field one country's atlas asks for
896
+ * doesn't belong on another country's enrollment.
897
+ */
898
+ function targetForm(forms, country, formId) {
899
+ const own = (form) => {
900
+ const ids = countryIdsOf(form);
901
+ return ids.length === 1 && ids[0] === country.id;
902
+ };
903
+ if (formId !== void 0) {
904
+ const form = forms.find((f) => f.id === formId);
905
+ if (!form) throw new LocalizationError(`The company has no form ${formId}.`);
906
+ if (!isPostPurchase(form)) throw new LocalizationError(`Form ${formId} ("${form.title}") is a ${form.form_type ?? "untyped"} form, not Post Purchase, so enrollment checkout doesn't show it.`);
907
+ if (!own(form)) throw new LocalizationError(`Form ${formId} ("${form.title}") isn't scoped to ${country.name} alone, so this command won't add country fields to it. Pick a form for ${country.name} only, or leave out --form to use or create "${enrollmentFormTitle(country.name)}".`);
908
+ return form;
909
+ }
910
+ const title = enrollmentFormTitle(country.name);
911
+ const candidates = forms.filter((f) => isPostPurchase(f) && own(f) && f.title === title);
912
+ return candidates.find(isActive) ?? [...candidates].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0] ?? null;
913
+ }
914
+ /** What the forms say about a country's atlas enrollment fields, and what
915
+ * --create would send. */
916
+ function formsReport(input) {
917
+ const { countryCode, country, atlas, options = {} } = input;
918
+ const fields = mapAtlasFields(atlas.enrollmentFormFields ?? []);
919
+ const forCountry = input.forms.filter((f) => isPostPurchase(f) && countryIdsOf(f).includes(country.id));
920
+ const target = targetForm(input.forms, country, options.formId);
921
+ const parsed = new Map(forCountry.map((f) => [f.id, componentsOf(f)]));
922
+ if (target && !parsed.has(target.id)) parsed.set(target.id, componentsOf(target));
923
+ const covers = (formId, field) => (parsed.get(formId) ?? []).some((c) => componentCovers(c, field));
924
+ const active = forCountry.filter(isActive);
925
+ const rows = fields.map((field) => {
926
+ const collectedBy = active.filter((f) => covers(f.id, field)).map((f) => f.id);
927
+ const { options: _options, excluded, ...rest } = field;
928
+ const state = excluded ? "byHand" : collectedBy.length > 0 ? "collected" : target && covers(target.id, field) ? "inDraft" : "missing";
929
+ return {
930
+ ...rest,
931
+ state,
932
+ collectedBy,
933
+ ...excluded ? { reason: EXCLUSION_REASONS[excluded] } : {}
934
+ };
935
+ });
936
+ const missing = rows.filter((r) => r.state === "missing");
937
+ const toAdd = fields.filter((f) => missing.some((m) => m.id === f.id && !(options.skipSensitive && f.sensitive)));
938
+ const skippedSensitive = options.skipSensitive ? missing.filter((m) => m.sensitive).map((m) => m.id) : [];
939
+ const title = target?.title ?? enrollmentFormTitle(country.name);
940
+ const wantsActivate = options.activate === true && (target ? !isActive(target) : true);
941
+ const action = toAdd.length > 0 ? target ? "update" : "create" : target && wantsActivate ? "update" : "none";
942
+ const activate = action !== "none" && wantsActivate;
943
+ const plan = {
944
+ action,
945
+ formId: target?.id ?? null,
946
+ title,
947
+ status: (target ? isActive(target) : false) || options.activate ? "active" : "draft",
948
+ add: toAdd.map((f) => ({
949
+ id: f.id,
950
+ label: f.label,
951
+ elementType: f.elementType,
952
+ puckType: f.puckType,
953
+ required: f.required,
954
+ sensitive: f.sensitive
955
+ })),
956
+ skippedSensitive,
957
+ activate
958
+ };
959
+ const report = {
960
+ country: countryCode,
961
+ countryId: country.id,
962
+ countryName: country.name,
963
+ done: missing.length === 0 && rows.every((r) => r.state !== "inDraft"),
964
+ forms: forCountry.map((f) => ({
965
+ id: f.id,
966
+ title: f.title,
967
+ status: f.status ?? null,
968
+ countries: (f.countries ?? []).map((c) => c.iso ?? String(c.id)),
969
+ shared: countryIdsOf(f).length > 1,
970
+ atlasFields: fields.filter((field) => covers(f.id, field)).map((x) => x.id),
971
+ components: parsed.get(f.id)?.length ?? 0
972
+ })),
973
+ target: target ? {
974
+ id: target.id,
975
+ title: target.title,
976
+ status: target.status ?? null
977
+ } : null,
978
+ fields: rows,
979
+ missing: missing.map((m) => m.id),
980
+ plan
981
+ };
982
+ if (fields.length === 0) {
983
+ report.note = `The atlas lists no enrollment fields for ${countryCode}, so there is nothing to add.`;
984
+ return report;
985
+ }
986
+ if (report.forms.some((f) => f.shared)) report.note = `Forms shared with other countries are read, not changed. A field they lack goes on ${countryCode}'s own form, which enrollment shows beside them.`;
987
+ const draftOnly = rows.filter((r) => r.state === "inDraft");
988
+ if (draftOnly.length > 0 && target && !isActive(target)) report.draft = `"${target.title}" (${target.id}) is a draft, so enrollment doesn't ask for ${draftOnly.map((r) => r.id).join(", ")} yet. Review it in the form builder and publish it, or rerun with --create --activate --yes.`;
989
+ if (action !== "none") report.next = `fluid countries forms ${countryCode} --create${options.formId !== void 0 ? ` --form ${options.formId}` : ""}${options.activate ? " --activate" : ""}${options.skipSensitive ? " --skip-sensitive" : ""} --yes`;
990
+ return report;
991
+ }
992
+ /** The message the forms API answers when the caller can't change which
993
+ * fields are sensitive. */
994
+ const SENSITIVE_DENIED = /permission to change which fields are marked sensitive/i;
995
+ /** Turn the forms API's sensitive-permission 422 into an actionable error. */
996
+ function explainSensitiveDenial(error, fields) {
997
+ if (statusOf(error) === 422 && SENSITIVE_DENIED.test(errorDetail(error).message)) throw Object.assign(new LocalizationError(`Your token can't create fields marked sensitive (${fields.join(", ")}): that needs the forms manage-sensitive permission. Nothing was saved. Run it with a token whose role has that permission, or rerun with --skip-sensitive to create the rest and leave those fields to someone who has it.`), { status: 422 });
998
+ throw error;
999
+ }
1000
+ /**
1001
+ * Apply a plan. A new form is created with the fields. An existing form is
1002
+ * read again first and every one of its components is sent back as it is,
1003
+ * ids included, with the new ones after them, so nothing on it is
1004
+ * deactivated and no field's sensitivity changes; fields an admin added since
1005
+ * the preview aren't added twice.
1006
+ */
1007
+ async function applyFormsPlan(client, report, fields) {
1008
+ const { plan } = report;
1009
+ const byId = new Map(fields.map((f) => [f.id, f]));
1010
+ const toAdd = plan.add.map((a) => byId.get(a.id));
1011
+ const sensitive = toAdd.filter((f) => f.sensitive).map((f) => f.id);
1012
+ const result = (action, form, added, activated) => ({
1013
+ action,
1014
+ form: form ? {
1015
+ id: form.id,
1016
+ title: form.title,
1017
+ status: form.status ?? null
1018
+ } : null,
1019
+ added,
1020
+ activated,
1021
+ skippedSensitive: plan.skippedSensitive
1022
+ });
1023
+ if (plan.action === "none") return result("none", null, [], false);
1024
+ if (plan.action === "create") {
1025
+ let body;
1026
+ try {
1027
+ body = await client.post(FORMS_PATH, { form: {
1028
+ title: plan.title,
1029
+ form_type: "post_purchase",
1030
+ status: plan.status,
1031
+ form_redirect: "",
1032
+ country_ids: [report.countryId],
1033
+ form_components: JSON.stringify(toAdd.map(componentFor))
1034
+ } });
1035
+ } catch (error) {
1036
+ explainSensitiveDenial(error, sensitive);
1037
+ }
1038
+ return result("created", body.form ?? null, toAdd.map((f) => f.id), plan.status === "active");
1039
+ }
1040
+ const current = await getForm(client, plan.formId);
1041
+ const existing = componentsOf(current);
1042
+ const stillMissing = toAdd.filter((f) => !existing.some((c) => componentCovers(c, f)));
1043
+ if (stillMissing.length === 0 && !plan.activate) return result("none", current, [], false);
1044
+ const unidentified = existing.filter((c) => c.id == null || c.id === "");
1045
+ if (stillMissing.length > 0 && unidentified.length > 0) throw new LocalizationError(`Form ${current.id} ("${current.title}") has ${unidentified.length} component(s) without an element id, so sending its components back could deactivate them. Open it in the form builder and save it once, then rerun.`);
1046
+ let body;
1047
+ try {
1048
+ body = await client.put(`${FORMS_PATH}/${current.id}`, { form: {
1049
+ ...stillMissing.length > 0 ? { form_components: JSON.stringify([...existing, ...stillMissing.map(componentFor)]) } : {},
1050
+ ...plan.activate ? { status: "active" } : {}
1051
+ } });
1052
+ } catch (error) {
1053
+ explainSensitiveDenial(error, stillMissing.filter((f) => f.sensitive).map((f) => f.id));
1054
+ }
1055
+ return result("updated", body.form ?? current, stillMissing.map((f) => f.id), plan.activate);
1056
+ }
1057
+ //#endregion
631
1058
  //#region src/countries/prices.ts
632
1059
  /** The money fields a conversion can write, as the variants API names them. */
633
1060
  const PRICE_FIELDS = [
@@ -1222,9 +1649,20 @@ function statusReport(input) {
1222
1649
  ok: input.prices.onSale > 0 || input.prices.bundles.length > 0,
1223
1650
  detail: input.prices.onSale > 0 ? `${input.prices.onSale} variants are on sale in ${countryCode}.` : input.prices.bundles.length > 0 ? `No single products are on sale in ${countryCode}; check the bundles under byHand.bundles.` : `Nothing is on sale in ${countryCode} yet. Turn on the products you'll sell there, or run prices with --activate.`
1224
1651
  }] : [];
1652
+ const enrollment = input.enrollment && !("unavailable" in input.enrollment) ? input.enrollment : null;
1653
+ const enrollmentChecks = enrollment ? [{
1654
+ id: "enrollment_form",
1655
+ ok: enrollment.done,
1656
+ detail: enrollment.done ? enrollment.fields.length === 0 ? `The atlas lists no enrollment fields for ${countryCode}.` : `Enrollment in ${countryCode} asks for every atlas field a Post Purchase form can collect.` : enrollment.missing.length > 0 ? `Enrollment in ${countryCode} doesn't ask for ${enrollment.missing.join(", ")}. Run \`fluid countries forms ${countryCode}\`.` : enrollment.draft ?? `The enrollment form for ${countryCode} isn't published.`
1657
+ }] : input.enrollment && "unavailable" in input.enrollment ? [{
1658
+ id: "enrollment_form",
1659
+ ok: false,
1660
+ detail: `Couldn't read the company's forms (${input.enrollment.unavailable}), so the enrollment fields weren't checked.`
1661
+ }] : [];
1225
1662
  const checks = [
1226
1663
  ...settingsChecks(atlas, cc),
1227
1664
  ...priceChecks,
1665
+ ...enrollmentChecks,
1228
1666
  ...languages.map((l) => ({
1229
1667
  id: `language:${l.iso}`,
1230
1668
  ok: l.enabled,
@@ -1249,6 +1687,10 @@ function statusReport(input) {
1249
1687
  report: `fluid translations missing ${iso}`,
1250
1688
  translate: `fluid translations auto ${iso} --write --yes`
1251
1689
  })),
1690
+ enrollmentForm: {
1691
+ report: `fluid countries forms ${countryCode}`,
1692
+ create: `fluid countries forms ${countryCode} --create --yes`
1693
+ },
1252
1694
  prices: {
1253
1695
  preview: `fluid countries prices ${countryCode}`,
1254
1696
  convert: `fluid countries prices ${countryCode} --rate <rate> --write --yes`
@@ -1263,7 +1705,13 @@ function statusReport(input) {
1263
1705
  name: m.name,
1264
1706
  recommended: m.enabled
1265
1707
  })),
1266
- enrollmentFields: [...atlas.enrollmentFormFields ?? []].sort((a, b) => a.order - b.order).map((f) => ({
1708
+ enrollmentFields: enrollment ? enrollment.fields.filter((f) => f.state === "byHand").map((f) => ({
1709
+ id: f.id,
1710
+ label: f.label,
1711
+ component: f.component,
1712
+ required: f.required,
1713
+ ...f.reason ? { reason: f.reason } : {}
1714
+ })) : [...atlas.enrollmentFormFields ?? []].sort((a, b) => a.order - b.order).map((f) => ({
1267
1715
  id: f.id,
1268
1716
  label: f.label,
1269
1717
  component: f.component,
@@ -3324,6 +3772,20 @@ async function withTranslationGaps(api, atlas, states) {
3324
3772
  async function countryPriceCoverage(api, iso) {
3325
3773
  return priceCoverage(await listProductsWithVariants(api, ["active"]), iso);
3326
3774
  }
3775
+ /** The country's enrollment form report, or the reason it couldn't be
3776
+ * read (a token without forms access), so status still runs. */
3777
+ async function countryEnrollment(api, countryCode, atlas) {
3778
+ try {
3779
+ return formsReport({
3780
+ countryCode,
3781
+ country: await catalogCountry(api, countryCode),
3782
+ atlas,
3783
+ forms: await listForms(api)
3784
+ });
3785
+ } catch (error) {
3786
+ return { unavailable: failureOf(error).error };
3787
+ }
3788
+ }
3327
3789
  function registerCountriesCommands(parent, ctx) {
3328
3790
  parent.command("atlas <iso>").description("Show Fluid's Country Atlas for a market: modes, tax, legal, address, payments, languages, agreements").option("--agreement <localId>", "Print one atlas agreement's full text in every language instead").action((iso, opts) => run(async () => {
3329
3791
  const result = await fetchAtlas(await client$1(ctx), iso);
@@ -3532,6 +3994,46 @@ function registerCountriesCommands(parent, ctx) {
3532
3994
  });
3533
3995
  if (failed.length > 0 || created.some((c) => c.failedTranslations.length > 0)) process.exitCode = 1;
3534
3996
  }));
3997
+ parent.command("forms <iso>").description("Compare the atlas enrollment fields with the country's Post Purchase forms; --create sets up its enrollment form, as a draft unless --activate (changes live config)").option("--create", "Create the country's enrollment form, or add the missing atlas fields to it", false).option("--form <id>", "With --create, add to this Post Purchase form for the country instead").option("--activate", "With --create, publish the form so enrollment asks for its fields now", false).option("--skip-sensitive", "With --create, leave out fields stored as sensitive, which need the forms manage-sensitive permission", false).option("--yes", "Confirm without a prompt", false).action((iso, opts) => run(async () => {
3998
+ if (!opts.create && (opts.form || opts.activate || opts.skipSensitive)) throw new LocalizationError("--form, --activate and --skip-sensitive go with --create.");
3999
+ const api = await client$1(ctx);
4000
+ const { countryCode, atlas } = await requireAtlas(api, iso);
4001
+ const country = await catalogCountry(api, countryCode);
4002
+ const options = {
4003
+ ...opts.form ? { formId: positiveInt(opts.form, "--form") } : {},
4004
+ activate: opts.activate,
4005
+ skipSensitive: opts.skipSensitive
4006
+ };
4007
+ const report = formsReport({
4008
+ countryCode,
4009
+ country,
4010
+ atlas,
4011
+ forms: await listForms(api),
4012
+ options
4013
+ });
4014
+ if (!opts.create || report.plan.action === "none") {
4015
+ ctx.output({
4016
+ ...report,
4017
+ written: false
4018
+ });
4019
+ return;
4020
+ }
4021
+ const { plan } = report;
4022
+ await confirmChange(`${plan.action === "create" ? `Create "${plan.title}" as a ${plan.status} Post Purchase form for ${countryCode}` : `Change form ${plan.formId} ("${plan.title}")`} ${[...plan.add.length > 0 ? [`with ${plan.add.length} field(s): ${plan.add.map((f) => `"${f.label}"${f.sensitive ? " (sensitive)" : ""}`).join(", ")}`] : [], ...plan.action === "update" && plan.activate ? ["and publish it, so enrollment asks for its fields"] : []].join(" ")}?`, opts.yes);
4023
+ const result = await applyFormsPlan(api, report, mapAtlasFields(atlas.enrollmentFormFields ?? []));
4024
+ const after = formsReport({
4025
+ countryCode,
4026
+ country,
4027
+ atlas,
4028
+ forms: await listForms(api),
4029
+ ...options.formId !== void 0 ? { options: { formId: options.formId } } : {}
4030
+ });
4031
+ ctx.output({
4032
+ ...after,
4033
+ written: result.action !== "none",
4034
+ ...result
4035
+ });
4036
+ }));
3535
4037
  parent.command("prices <iso>").description("Convert every variant's price from another country into this one's currency at an exchange rate. A dry run unless --write (changes live prices)").option("--from-country <iso>", "The country whose prices are converted", "US").option("--rate <rate>", "Units of the new currency per unit of the source currency, e.g. 0.92; --write needs it").option("--fields <list>", `Price fields to convert, comma-separated, from ${PRICE_FIELDS.join(", ")}`, DEFAULT_PRICE_FIELDS.join(",")).option("--status <status>", "Product status to include (repeatable)", collect, []).option("--overwrite", "Also replace prices the country already has", false).option("--activate", "Put each converted variant on sale in the country", false).option("--sample <n>", "How many before→after examples to print", "10").option("--write", "Save the converted prices; without it nothing is written", false).option("--yes", "Confirm --write without a prompt", false).action((iso, opts) => run(async () => {
3536
4038
  const api = await client$1(ctx);
3537
4039
  const targetIso = countryCodeOf(iso);
@@ -3643,13 +4145,15 @@ function registerCountriesCommands(parent, ctx) {
3643
4145
  const { countryCode, atlas } = await requireAtlas(api, iso);
3644
4146
  const cc = await findCompanyCountry(api, countryCode);
3645
4147
  const [languages, agreements] = cc ? await Promise.all([listLanguages(api), listAgreements(api)]) : [[], []];
4148
+ const enrollment = cc ? await countryEnrollment(api, countryCode, atlas) : void 0;
3646
4149
  ctx.output(statusReport({
3647
4150
  countryCode,
3648
4151
  atlas,
3649
4152
  companyCountry: cc,
3650
4153
  languages: languageStates(atlas, languages),
3651
4154
  agreements: await withTranslationGaps(api, atlas, agreementStates(atlas, agreements, cc?.id ?? null)),
3652
- ...cc ? { prices: await countryPriceCoverage(api, countryCode) } : {}
4155
+ ...cc ? { prices: await countryPriceCoverage(api, countryCode) } : {},
4156
+ ...enrollment ? { enrollment } : {}
3653
4157
  }));
3654
4158
  }));
3655
4159
  }