@wrangled/cli 0.3.148 → 0.3.150
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/guide.js +2 -0
- package/dist/main.js +66 -30
- package/dist/report-columns.js +57 -0
- package/package.json +1 -1
package/dist/guide.js
CHANGED
|
@@ -156,6 +156,7 @@ For "how is the funnel doing?", "which campaigns are working?" and "where does t
|
|
|
156
156
|
- \`${cli} funnel [--grain day|week|month]\` prints ad spend, leads, booked calls, calls held, sales and net revenue per day, week (starting Monday) or month, with cost per lead, per booked call and per sale and ROAS, then totals for the whole range.
|
|
157
157
|
- \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, unknown or recorded_in_crm.
|
|
158
158
|
- \`${cli} money [--grain day|week|month]\` prints money in (gross, refunds, net, orders), ad spend, affiliate payouts, what is left after ad costs and ROAS per period, then totals for the whole range, money in per payment source (stripe, ghl) and the biggest refund days. Refunds come off on the day they were made, not the day of the sale. \`left_after_ad_costs\` is net less ad spend and affiliate payouts; it is not profit. Its \`roas\` is net over ad spend alone, so it differs from \`${cli} funnel\`'s, which divides by all spend including affiliate payouts.
|
|
159
|
+
- When no payment tool, store or CRM sales are connected but Triple Whale is, \`${cli} money\` takes money in from Triple Whale's store totals and \`${cli} campaigns\` adds the orders, revenue and ROAS Triple Whale credits to each Facebook campaign (\`orders_triple_whale\`, \`revenue_triple_whale\`, \`roas_triple_whale\`), counted as \`${cli} winners\` counts them. Each says so under its table. Triple Whale can credit one order to several ads, so campaign orders can add up to more than the store's. With neither, each report says what is missing and leaves out the columns that would be blank.
|
|
159
160
|
- Money is never converted or added across currencies: there is one row per currency, and one total per currency. In \`${cli} funnel\`, leads and calls are counted for the whole period and repeat on every currency row, so never add them across its rows. In \`${cli} campaigns\`, each campaign's leads and calls sit on the row of the currency it spends in (or a blank-currency row when it has no spend), so they do add up across rows and totals. Ratios come from the summed numbers, not averages. An empty cell means the source is not connected, or a ratio would divide by 0.
|
|
160
161
|
- \`${cli} metrics query --metric <name> [--by <dimension>]...\` gives any of the standard numbers by name, split and filtered your way, such as \`--metric spend --metric cost_per_lead --by channel --where channel!=affiliate --sort=-spend\`. It sums the parts before dividing, keeps currencies apart, credits leads and sales by channel or campaign to the first touch (\`--attribution last_touch\` for the last), and prints an empty number, never 0, for a source that is not connected. An unknown name fails with the list of real ones. Use it before writing SQL for a number these reports do not split the way you need.
|
|
161
162
|
- \`${cli} metrics search "<everyday words>"\` finds the name \`metrics query\` takes from your own words, such as \`"show rate"\`, \`"cost per booked call"\` or \`"first time buyers"\`: each match with what it means, which of the client's sources feed it and whether they are connected, and for a split like channel or campaign up to five of the client's own values. \`${cli} metrics list\` prints every number and split. The client's own confirmed labels and numbers show beside the catalog's, marked "(custom)". Search before guessing a name.
|
|
@@ -567,6 +568,7 @@ ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--j
|
|
|
567
568
|
${cli} number-alerts [--since <date>] the key numbers that swung far from normal, day by day
|
|
568
569
|
${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
|
|
569
570
|
${cli} lessons [--status active|retired] [--about ads|pages|funnel|messages|cross_channel] [--dimension <d>] | lessons add --file <lesson.json> | lessons retire <id> --note "<why>" lessons from winners and flops, writing one, retiring one
|
|
571
|
+
${cli} tests p1 this brand's P1 test ads by verdict, judged on its own sales against its P1 thresholds, with Meta's count beside
|
|
570
572
|
${cli} tests [--status <status>] [--funnel <key>] [--limit <n>] | tests show <id> | tests draft --file <test.json> | tests start <id> | tests abandon <id> [--note "<why>"] | tests results <id> | tests decide <id> --decision <d> [--note "<why>"] funnel tests, one change each
|
|
571
573
|
${cli} ads tags [--ad <id>] [--status <status>] [--limit <n>] | ads tag --file <tags.json> the marketing tags of ads not made here, and suggesting them
|
|
572
574
|
${cli} ads names [--unparsed] Meta ad names and the tags each gives; --unparsed lists those that fit no pattern
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { basename, dirname, extname, join, resolve } from "node:path";
|
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, DOCUMENT_LIMITS, META_IDS_MAX, PROFILE_MESSAGING_KINDS, OUTCOME_PARTS, FACET_WORDS, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, AGENT_TABLE_LIMITS, AGENT_TABLE_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, OUTBOUND_PUSH_MAX, entryTagFlags, streamSigned, } from "./api.js";
|
|
9
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
|
+
import { campaignLayout, money, moneyLayout } from "./report-columns.js";
|
|
10
11
|
import { renderDataMap, renderQuestions } from "./map.js";
|
|
11
12
|
import { apiSetupPath, parseAuthFlag, parseQueryFlags, setupText, submittedText, testText, tryText, waitingText, } from "./api-setup.js";
|
|
12
13
|
import { answerRow, batches, ENRICH_EMAIL_USAGE, parsePeople, parseSteps } from "./enrich.js";
|
|
@@ -242,7 +243,7 @@ const commands = {
|
|
|
242
243
|
const { funnel } = await api(await resolveConnection(options), `/api/v1/reports/funnel${query}`);
|
|
243
244
|
if (options.format === "json")
|
|
244
245
|
return printJson({ funnel });
|
|
245
|
-
printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
|
|
246
|
+
printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], { numbers: FUNNEL_NUMBERS, before: [], after: [] });
|
|
246
247
|
printLeftOut(funnel.left_out, "sales, revenue_net and roas");
|
|
247
248
|
},
|
|
248
249
|
},
|
|
@@ -480,7 +481,7 @@ const commands = {
|
|
|
480
481
|
const { money } = await api(await resolveConnection(options), `/api/v1/reports/money${query}`);
|
|
481
482
|
if (options.format === "json")
|
|
482
483
|
return printJson({ money });
|
|
483
|
-
printReport(`Money by ${money.grain}`, money, ["period", "currency"],
|
|
484
|
+
printReport(`Money by ${money.grain}`, money, ["period", "currency"], moneyLayout(money));
|
|
484
485
|
if (money.by_source.length > 0)
|
|
485
486
|
console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
|
|
486
487
|
if (money.store_orders_in_stripe?.length) {
|
|
@@ -552,7 +553,7 @@ const commands = {
|
|
|
552
553
|
if (options.format === "json")
|
|
553
554
|
return printJson({ campaignResults });
|
|
554
555
|
const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
|
|
555
|
-
printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"],
|
|
556
|
+
printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], campaignLayout(campaignResults));
|
|
556
557
|
printLeftOut(campaignResults.left_out, "the totals (the rows keep it)");
|
|
557
558
|
},
|
|
558
559
|
},
|
|
@@ -3154,6 +3155,17 @@ const commands = {
|
|
|
3154
3155
|
console.log(renderTable(TEST_COLUMNS, tests.map(testRow)));
|
|
3155
3156
|
},
|
|
3156
3157
|
},
|
|
3158
|
+
"tests p1": {
|
|
3159
|
+
usage: "tests p1 this brand's P1 test ads by verdict (working, not working, paused, ready for P3, still testing), judged on its own sales against its P1 thresholds",
|
|
3160
|
+
async run(args, options) {
|
|
3161
|
+
if (args.length > 0)
|
|
3162
|
+
throw new CliError("Usage: tests p1", exitCodes.usage);
|
|
3163
|
+
const report = await api(await resolveConnection(options), "/api/v1/p1-tests");
|
|
3164
|
+
if (options.format === "json")
|
|
3165
|
+
return printJson(report);
|
|
3166
|
+
process.stdout.write(p1TestsText(report));
|
|
3167
|
+
},
|
|
3168
|
+
},
|
|
3157
3169
|
"tests show": {
|
|
3158
3170
|
usage: "tests show <id|test_id> one funnel test in full",
|
|
3159
3171
|
async run(args, options) {
|
|
@@ -4092,27 +4104,6 @@ function historyText(history) {
|
|
|
4092
4104
|
return main + catchingUp.join("");
|
|
4093
4105
|
}
|
|
4094
4106
|
const FUNNEL_NUMBERS = ["spend", "leads", "booked_calls", "calls_held", "sales_count", "revenue_net", "new_buyers", "cost_per_lead", "cost_per_booked_call", "cost_per_sale", "cost_per_new_buyer", "roas"];
|
|
4095
|
-
const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
|
|
4096
|
-
const CAMPAIGN_NUMBERS = [
|
|
4097
|
-
"spend",
|
|
4098
|
-
"leads_first_touch",
|
|
4099
|
-
"leads_last_touch",
|
|
4100
|
-
"booked_calls_first_touch",
|
|
4101
|
-
"sales_first_touch",
|
|
4102
|
-
"revenue_first_touch",
|
|
4103
|
-
"revenue_last_touch",
|
|
4104
|
-
"cost_per_lead_first_touch",
|
|
4105
|
-
"roas_first_touch",
|
|
4106
|
-
"roas_last_touch",
|
|
4107
|
-
];
|
|
4108
|
-
function money(value, currency) {
|
|
4109
|
-
try {
|
|
4110
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
|
|
4111
|
-
}
|
|
4112
|
-
catch {
|
|
4113
|
-
return `${value.toFixed(2)} ${currency}`;
|
|
4114
|
-
}
|
|
4115
|
-
}
|
|
4116
4107
|
function newIdeasLine({ target, proposedToday, stalled, costPerResult: cpr }, ideasPerDay) {
|
|
4117
4108
|
const split = `${target} of ${ideasPerDay}${proposedToday > 0 ? `, ${proposedToday} already proposed` : ""}`;
|
|
4118
4109
|
if (!cpr)
|
|
@@ -4247,6 +4238,45 @@ const FIRST_TOUCH_SALE_RULE = "Each sale goes to the Facebook ad that first brou
|
|
|
4247
4238
|
* The winners report as plain Markdown: which goal the ads are judged on and what one means, then the dates asked for and,
|
|
4248
4239
|
* unless they are the same dates, the last 30 days, each currency apart.
|
|
4249
4240
|
*/
|
|
4241
|
+
const P1_GROUPS = [
|
|
4242
|
+
["working", "Working"],
|
|
4243
|
+
["not_working", "Not working"],
|
|
4244
|
+
["paused", "Paused"],
|
|
4245
|
+
["ready_for_p3", "Ready for P3"],
|
|
4246
|
+
["testing", "Still testing"],
|
|
4247
|
+
[null, "No verdict"],
|
|
4248
|
+
];
|
|
4249
|
+
function p1TestsText(report) {
|
|
4250
|
+
const { window, thresholds: t, judgedOn } = report;
|
|
4251
|
+
const lines = [`P1 tests for ${report.brand}, ${window.from} to ${window.to} (${window.days} days), judged on ${judgedOn.label}`];
|
|
4252
|
+
const amount = (n, currency) => (n === null ? "" : currency === null ? n.toFixed(2) : money(n, currency));
|
|
4253
|
+
if (t !== null) {
|
|
4254
|
+
const currency = report.ads.find((ad) => ad.currency !== null)?.currency ?? null;
|
|
4255
|
+
lines.push(`Thresholds: not working after ${amount(t.minSpend, currency)} spent; working at ${amount(t.maxCostPerResult, currency)} or less per ${judgedOn.one}; ` +
|
|
4256
|
+
`ready for P3 at ${t.p3MinResults} or more at ${amount(t.p3MaxCostPerResult, currency)} or less each`);
|
|
4257
|
+
}
|
|
4258
|
+
if (report.missing !== null)
|
|
4259
|
+
lines.push(report.missing);
|
|
4260
|
+
const columns = ["ad", "campaign", "spend", `results (${judgedOn.label})`, "Meta's count", "cost per result", "why"];
|
|
4261
|
+
for (const [verdict, title] of P1_GROUPS) {
|
|
4262
|
+
const ads = report.ads.filter((ad) => ad.verdict === verdict);
|
|
4263
|
+
if (ads.length === 0)
|
|
4264
|
+
continue;
|
|
4265
|
+
const rows = ads.map((ad) => ({
|
|
4266
|
+
ad: `${ad.name} (${ad.adId})`,
|
|
4267
|
+
campaign: ad.campaignName,
|
|
4268
|
+
spend: amount(ad.spend, ad.currency),
|
|
4269
|
+
[columns[3]]: String(ad.results),
|
|
4270
|
+
"Meta's count": ad.metaResults === null ? "" : `${ad.metaResults}${ad.metaDisagrees ? " (differs)" : ""}`,
|
|
4271
|
+
"cost per result": amount(ad.costPerResult, ad.currency),
|
|
4272
|
+
why: ad.because,
|
|
4273
|
+
}));
|
|
4274
|
+
lines.push("", `${title} (${ads.length})`, renderTable(columns, rows));
|
|
4275
|
+
}
|
|
4276
|
+
if (report.ads.length === 0)
|
|
4277
|
+
lines.push("", "No live ads in the P1 campaigns.");
|
|
4278
|
+
return `${lines.join("\n")}\n`;
|
|
4279
|
+
}
|
|
4250
4280
|
function winnersText(report) {
|
|
4251
4281
|
const { week, last30Days } = report;
|
|
4252
4282
|
const words = winnersWords(report);
|
|
@@ -4528,13 +4558,19 @@ function printLeftOut(leftOut, figures) {
|
|
|
4528
4558
|
const rows = leftOut.flatMap((l) => l.amounts.map((a) => ({ fact: l.fact_id, currency: a.currency, revenue: a.revenue, sales: a.sales, rule: l.summary })));
|
|
4529
4559
|
console.log(`\nLeft out of ${figures} by confirmed leave-out rules:\n${renderTable(["fact", "currency", "revenue", "sales", "rule"], rows)}`);
|
|
4530
4560
|
}
|
|
4531
|
-
function printReport(title, report, labels, numbers) {
|
|
4561
|
+
function printReport(title, report, labels, { numbers, before, after }) {
|
|
4562
|
+
if (before.length > 0)
|
|
4563
|
+
console.log(`${before.join("\n")}\n`);
|
|
4532
4564
|
if (report.rows.length === 0)
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4565
|
+
console.log(`No numbers from ${report.from} to ${report.to}.`);
|
|
4566
|
+
else {
|
|
4567
|
+
console.log(`${title}, ${report.from} to ${report.to}:`);
|
|
4568
|
+
console.log(renderTable([...labels, ...numbers], report.rows));
|
|
4569
|
+
console.log("\nTotals:");
|
|
4570
|
+
console.log(renderTable(["currency", ...numbers], report.totals));
|
|
4571
|
+
}
|
|
4572
|
+
for (const line of after)
|
|
4573
|
+
console.log(line);
|
|
4538
4574
|
}
|
|
4539
4575
|
const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
|
|
4540
4576
|
const FACTS_PATH = "/api/v1/facts";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { brand } from "./brand.js";
|
|
2
|
+
export const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
|
|
3
|
+
export const CAMPAIGN_NUMBERS = [
|
|
4
|
+
"spend",
|
|
5
|
+
"leads_first_touch",
|
|
6
|
+
"leads_last_touch",
|
|
7
|
+
"booked_calls_first_touch",
|
|
8
|
+
"sales_first_touch",
|
|
9
|
+
"revenue_first_touch",
|
|
10
|
+
"revenue_last_touch",
|
|
11
|
+
"cost_per_lead_first_touch",
|
|
12
|
+
"roas_first_touch",
|
|
13
|
+
"roas_last_touch",
|
|
14
|
+
];
|
|
15
|
+
const MONEY_IN_NUMBERS = new Set(["gross", "refunds", "net", "orders", "left_after_ad_costs", "roas"]);
|
|
16
|
+
const TRIPLE_WHALE_NUMBERS = ["orders_triple_whale", "revenue_triple_whale", "roas_triple_whale"];
|
|
17
|
+
const NO_CRM_SALES = "no payment tool, store or CRM sales are connected";
|
|
18
|
+
export function money(value, currency) {
|
|
19
|
+
try {
|
|
20
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return `${value.toFixed(2)} ${currency}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A server older than this CLI sends no sales_from; its money in is daily_sales'. */
|
|
27
|
+
export function moneyLayout({ sales_from: from }) {
|
|
28
|
+
if (from === undefined || from.source === "daily_sales")
|
|
29
|
+
return { numbers: MONEY_NUMBERS, before: [], after: [] };
|
|
30
|
+
if (from.source === null)
|
|
31
|
+
return { numbers: MONEY_NUMBERS.filter((n) => !MONEY_IN_NUMBERS.has(n)), before: [from.why], after: [] };
|
|
32
|
+
return { numbers: MONEY_NUMBERS, before: [], after: [`\nMoney in is Triple Whale's ${from.detail}, because ${NO_CRM_SALES}.`] };
|
|
33
|
+
}
|
|
34
|
+
const blankEverywhere = (column, rows) => rows.every((row) => row[column] === null || row[column] === undefined);
|
|
35
|
+
/**
|
|
36
|
+
* Without daily_sales, the first- and last-touch columns blank in every row and total are left out, and with Triple
|
|
37
|
+
* Whale its own orders, revenue and return on ad spend are added.
|
|
38
|
+
*/
|
|
39
|
+
export function campaignLayout(report) {
|
|
40
|
+
const from = report.sales_from;
|
|
41
|
+
if (from === undefined || from.source === "daily_sales")
|
|
42
|
+
return { numbers: CAMPAIGN_NUMBERS, before: [], after: [] };
|
|
43
|
+
const all = [...report.rows, ...report.totals];
|
|
44
|
+
const touches = CAMPAIGN_NUMBERS.filter((n) => n === "spend" || !blankEverywhere(n, all));
|
|
45
|
+
if (from.source === null)
|
|
46
|
+
return { numbers: touches, before: [from.why], after: [] };
|
|
47
|
+
const others = (report.triple_whale_other_campaigns ?? []).map(({ currency, orders, revenue }) => `Triple Whale credits ${orders} more ${orders === 1 ? "order" : "orders"}${revenue === null || currency === null ? "" : ` (${money(revenue, currency)})`} to Facebook ads that aren't in the ad accounts ${brand.name} is connected to, so they aren't in the rows above.`);
|
|
48
|
+
return {
|
|
49
|
+
numbers: [...touches, ...TRIPLE_WHALE_NUMBERS],
|
|
50
|
+
before: [],
|
|
51
|
+
after: [
|
|
52
|
+
`\nOrders and revenue are what Triple Whale credits to each Facebook campaign (${from.detail}), because ${NO_CRM_SALES}. ` +
|
|
53
|
+
"Triple Whale can credit one order to more than one ad, so these can add up to more than the store's own orders.",
|
|
54
|
+
...others,
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|