@fluid-app/fluid-cli-payments 0.1.147 → 0.1.149
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 +53 -1
- package/dist/index.d.mts +1 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +391 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Fluid CLI plugin for payments onboarding: the KYC form at
|
|
4
4
|
**Settings → Onboarding** in the admin. See what's missing, fill in fields and
|
|
5
|
-
documents, and submit the form for review.
|
|
5
|
+
documents, and submit the form for review. Once payments are set up,
|
|
6
|
+
[`test-order`](#test-order) places one order on the company's test gateway.
|
|
6
7
|
|
|
7
8
|
```bash
|
|
8
9
|
npm install -g @fluid-app/fluid-cli @fluid-app/fluid-cli-payments
|
|
@@ -67,3 +68,54 @@ non-zero. Commands that change data ask for confirmation unless you pass
|
|
|
67
68
|
|
|
68
69
|
The walkthrough is the payments onboarding guided setup at
|
|
69
70
|
https://docs.fluid.app/setup.
|
|
71
|
+
|
|
72
|
+
## Test order
|
|
73
|
+
|
|
74
|
+
`test-order` proves checkout works for a new company by placing one order on
|
|
75
|
+
its test gateway, with you as the buyer:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
fluid payments test-order --product 501 --accept-agreements --yes
|
|
79
|
+
fluid payments test-order --variant 9001 --country CA --accept-agreements --yes \
|
|
80
|
+
--address '{"address1":"100 King St W","city":"Toronto","state":"ON","postal_code":"M5X 1A9"}'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The buyer is the signed-in person: the order uses your email, and `--email`
|
|
84
|
+
can only repeat it. Checkout records the store's required agreements as
|
|
85
|
+
accepted by the buyer, so a test order needs a person's sign-in (`fluid login`
|
|
86
|
+
with email) and refuses a company API token with `PERSON_SIGN_IN_REQUIRED`.
|
|
87
|
+
When the store has required agreements, it stops with
|
|
88
|
+
`AGREEMENTS_NOT_ACCEPTED` and their titles until you read them and pass
|
|
89
|
+
`--accept-agreements`.
|
|
90
|
+
|
|
91
|
+
It builds a cart for one unit (`--product` buys the product's master
|
|
92
|
+
variant, so a draft product works too), sets the email, address and the
|
|
93
|
+
first shipping method, then pays with the test card `4242 4242 4242 4242`
|
|
94
|
+
and prints the order's id and number as JSON. The built-in address is in the US. Outside
|
|
95
|
+
the US, pass a real `--address`, since tax is quoted for it.
|
|
96
|
+
|
|
97
|
+
Before anything is paid for, it checks that every payment method the cart
|
|
98
|
+
offers is a test card on the Bogus gateway, all on one payment account, and
|
|
99
|
+
charges only that account. Otherwise it stops with a `code`, such as
|
|
100
|
+
`NO_PAYMENT_ACCOUNT` (a company that signed up recently may need to confirm
|
|
101
|
+
its signup first) or `NOT_TEST_GATEWAY`. A bad email or address also fails before
|
|
102
|
+
anything is paid for. It names the agreements you accept in the question and
|
|
103
|
+
the output, and sends back the version of their text it was shown, so
|
|
104
|
+
checkout refuses if the wording changes before the order completes. Agents pass `--yes`, and people at a
|
|
105
|
+
terminal are asked; without either it stops with `CONFIRMATION_REQUIRED`,
|
|
106
|
+
and a "no" stops with `CANCELLED`. Just before the order completes it reads
|
|
107
|
+
the cart again and repeats the payment checks, stopping with
|
|
108
|
+
`PAYMENT_ACCOUNT_CHANGED` if the account it checked is no longer the one
|
|
109
|
+
the cart would charge. Use the same environment as your login
|
|
110
|
+
(`--base-url`).
|
|
111
|
+
|
|
112
|
+
The order is real, though no money moves. The confirmation email goes to
|
|
113
|
+
you, order webhooks fire, and inventory is adjusted as for any order.
|
|
114
|
+
Checkout finds or creates a customer (and member) for your email in the
|
|
115
|
+
store and records the agreement acceptances against it. Nothing marks the
|
|
116
|
+
order as a test, so it shows in sales reports until it's refunded. Refund
|
|
117
|
+
and cancel it in the admin if you don't want it in the order list. Each run
|
|
118
|
+
tries once and places a new order. A failure after the cart exists gives
|
|
119
|
+
the cart's `cartId` and the time in `details` (never the cart token, which
|
|
120
|
+
opens the cart). If it fails with `ORDER_OUTCOME_UNKNOWN`, look in the
|
|
121
|
+
admin for an order from your email at that time before running it again.
|
package/dist/index.d.mts
CHANGED
|
@@ -2,19 +2,8 @@ import { CommandContext, FluidPlugin } from "@fluid-app/fluid-cli";
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
|
|
4
4
|
//#region src/index.d.ts
|
|
5
|
-
/**
|
|
6
|
-
* Changes to the onboarding form need an explicit yes. Scripts and agents
|
|
7
|
-
* pass `--yes`; an interactive terminal is asked. With neither, nothing runs.
|
|
8
|
-
*/
|
|
9
|
-
declare function confirmChange(question: string, yes: boolean, interactive?: boolean): Promise<void>;
|
|
10
|
-
/**
|
|
11
|
-
* Print a failure as JSON on stderr and exit non-zero. The API's error body
|
|
12
|
-
* names fields and reasons, never the submitted values, so its `errors` are
|
|
13
|
-
* passed through as `details`.
|
|
14
|
-
*/
|
|
15
|
-
declare function fail(err: unknown, write?: (text: string) => void): void;
|
|
16
5
|
declare function registerOnboardingCommands(parent: Command, ctx: CommandContext): void;
|
|
17
6
|
declare const plugin: FluidPlugin;
|
|
18
7
|
//#endregion
|
|
19
|
-
export {
|
|
8
|
+
export { plugin as default, registerOnboardingCommands };
|
|
20
9
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;iBAyDgB,0BAAA,CACd,MAAA,EAAQ,OAAA,EACR,GAAA,EAAK,cAAA;AAAA,cAiUD,MAAA,EAAQ,WAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import prompts from "prompts";
|
|
2
1
|
import { NAV_HELP, assertInteractive, createDomainCommand, terminalPrompter } from "@fluid-app/fluid-cli";
|
|
3
2
|
import { readFile } from "node:fs/promises";
|
|
4
3
|
import { basename, extname } from "node:path";
|
|
4
|
+
import prompts from "prompts";
|
|
5
5
|
//#region src/fields.ts
|
|
6
6
|
const STEPS = [
|
|
7
7
|
{
|
|
@@ -588,9 +588,12 @@ function stepReports(snapshot, missing) {
|
|
|
588
588
|
//#endregion
|
|
589
589
|
//#region src/types.ts
|
|
590
590
|
var OnboardingError = class extends Error {
|
|
591
|
-
|
|
591
|
+
/** `code` is a stable, machine-readable name for the failure, printed
|
|
592
|
+
* beside the message so a script can branch on it. */
|
|
593
|
+
constructor(message, details, code) {
|
|
592
594
|
super(message);
|
|
593
595
|
this.details = details;
|
|
596
|
+
this.code = code;
|
|
594
597
|
this.name = "OnboardingError";
|
|
595
598
|
}
|
|
596
599
|
};
|
|
@@ -1728,28 +1731,27 @@ async function walkOnboarding(deps, opts = {}) {
|
|
|
1728
1731
|
};
|
|
1729
1732
|
}
|
|
1730
1733
|
//#endregion
|
|
1731
|
-
//#region src/
|
|
1732
|
-
async function client(ctx) {
|
|
1733
|
-
return await ctx.getClient();
|
|
1734
|
-
}
|
|
1734
|
+
//#region src/cli.ts
|
|
1735
1735
|
/**
|
|
1736
1736
|
* Changes to the onboarding form need an explicit yes. Scripts and agents
|
|
1737
|
-
* pass `--yes`; an interactive terminal is asked. With neither, nothing runs
|
|
1737
|
+
* pass `--yes`; an interactive terminal is asked. With neither, nothing runs,
|
|
1738
|
+
* and the refusal's code (CONFIRMATION_REQUIRED, or CANCELLED for a "no")
|
|
1739
|
+
* lets a script tell it from a failure.
|
|
1738
1740
|
*/
|
|
1739
1741
|
async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
|
|
1740
1742
|
if (yes) return;
|
|
1741
|
-
if (!interactive) throw new OnboardingError(`${question} Pass --yes to confirm
|
|
1743
|
+
if (!interactive) throw new OnboardingError(`${question} Pass --yes to confirm.`, void 0, "CONFIRMATION_REQUIRED");
|
|
1742
1744
|
if (!(await prompts({
|
|
1743
1745
|
type: "confirm",
|
|
1744
1746
|
name: "ok",
|
|
1745
1747
|
message: question,
|
|
1746
1748
|
initial: false
|
|
1747
|
-
})).ok) throw new OnboardingError("Cancelled.");
|
|
1749
|
+
})).ok) throw new OnboardingError("Cancelled.", void 0, "CANCELLED");
|
|
1748
1750
|
}
|
|
1749
1751
|
/**
|
|
1750
1752
|
* Print a failure as JSON on stderr and exit non-zero. The API's error body
|
|
1751
1753
|
* names fields and reasons, never the submitted values, so its `errors` are
|
|
1752
|
-
* passed through as `details`.
|
|
1754
|
+
* passed through as `details`. An `OnboardingError`'s `code` is printed too.
|
|
1753
1755
|
*/
|
|
1754
1756
|
function fail(err, write = (t) => process.stderr.write(t)) {
|
|
1755
1757
|
const status = typeof err.status === "number" ? err.status : void 0;
|
|
@@ -1757,8 +1759,10 @@ function fail(err, write = (t) => process.stderr.write(t)) {
|
|
|
1757
1759
|
const apiDetail = body && (body["errors"] ?? body["error"] ?? body["error_message"]);
|
|
1758
1760
|
const message = typeof body?.["error_message"] === "string" ? body["error_message"] : typeof apiDetail === "string" ? apiDetail : err instanceof Error ? err.message : String(err);
|
|
1759
1761
|
const details = err instanceof OnboardingError && err.details !== void 0 ? err.details : apiDetail && typeof apiDetail === "object" ? apiDetail : void 0;
|
|
1762
|
+
const code = err instanceof OnboardingError ? err.code : void 0;
|
|
1760
1763
|
write(JSON.stringify({
|
|
1761
1764
|
error: message,
|
|
1765
|
+
...code ? { code } : {},
|
|
1762
1766
|
...status ? { status } : {},
|
|
1763
1767
|
...details !== void 0 ? { details } : {}
|
|
1764
1768
|
}) + "\n");
|
|
@@ -1767,6 +1771,378 @@ function fail(err, write = (t) => process.stderr.write(t)) {
|
|
|
1767
1771
|
function run(action) {
|
|
1768
1772
|
return action().catch((err) => fail(err));
|
|
1769
1773
|
}
|
|
1774
|
+
//#endregion
|
|
1775
|
+
//#region src/test-order.ts
|
|
1776
|
+
const CART_TIMEOUT_MS = 15e3;
|
|
1777
|
+
const COMPLETE_TIMEOUT_MS = 6e4;
|
|
1778
|
+
const CHECKOUT = "/api/checkout/v2026-04/carts";
|
|
1779
|
+
const PAYMENTS = "/api/payments/v2026-04/carts";
|
|
1780
|
+
const TOKENIZE_CARD = "/api/v202506/payments/tokenize_card";
|
|
1781
|
+
const TEST_CARD = "4242424242424242";
|
|
1782
|
+
/** A real address, so a tax quote for it succeeds. */
|
|
1783
|
+
const US_ADDRESS = {
|
|
1784
|
+
address1: "1200 Market St",
|
|
1785
|
+
city: "San Francisco",
|
|
1786
|
+
state: "CA",
|
|
1787
|
+
postal_code: "94103"
|
|
1788
|
+
};
|
|
1789
|
+
const DEFAULT_NAME = "Test Order";
|
|
1790
|
+
function refusal(code, message, details) {
|
|
1791
|
+
return new OnboardingError(message, details, code);
|
|
1792
|
+
}
|
|
1793
|
+
function positiveId(raw, flag) {
|
|
1794
|
+
const value = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
1795
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw refusal("FLAGS_INVALID", `${flag} takes a numeric id, not "${raw}".`);
|
|
1796
|
+
return value;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* The ship-to and bill-to address. Checkout permits `name`, not
|
|
1800
|
+
* `first_name`/`last_name`, so only `name` is sent.
|
|
1801
|
+
*/
|
|
1802
|
+
function buildAddress(raw, country) {
|
|
1803
|
+
if (raw === void 0) {
|
|
1804
|
+
if (country !== "US") throw refusal("ADDRESS_REQUIRED", `The built-in address is in the US. Pass --address '{"address1":…,"city":…,"postal_code":…}' with a real address in ${country}.`);
|
|
1805
|
+
return {
|
|
1806
|
+
name: DEFAULT_NAME,
|
|
1807
|
+
...US_ADDRESS,
|
|
1808
|
+
country_code: country
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
let parsed;
|
|
1812
|
+
try {
|
|
1813
|
+
parsed = JSON.parse(raw);
|
|
1814
|
+
} catch {
|
|
1815
|
+
throw refusal("FLAGS_INVALID", "--address must be a JSON object.");
|
|
1816
|
+
}
|
|
1817
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw refusal("FLAGS_INVALID", "--address must be a JSON object.");
|
|
1818
|
+
const given = parsed;
|
|
1819
|
+
const text = (key) => typeof given[key] === "string" && given[key].trim() ? given[key].trim() : void 0;
|
|
1820
|
+
const missing = [
|
|
1821
|
+
"address1",
|
|
1822
|
+
"city",
|
|
1823
|
+
"postal_code"
|
|
1824
|
+
].filter((k) => !text(k));
|
|
1825
|
+
if (missing.length) throw refusal("FLAGS_INVALID", `--address needs ${missing.join(", ")}.`);
|
|
1826
|
+
const address = { name: text("name") ?? DEFAULT_NAME };
|
|
1827
|
+
for (const key of [
|
|
1828
|
+
"address1",
|
|
1829
|
+
"address2",
|
|
1830
|
+
"city",
|
|
1831
|
+
"state",
|
|
1832
|
+
"postal_code"
|
|
1833
|
+
]) {
|
|
1834
|
+
const value = text(key);
|
|
1835
|
+
if (value) address[key] = value;
|
|
1836
|
+
}
|
|
1837
|
+
return {
|
|
1838
|
+
...address,
|
|
1839
|
+
country_code: country
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Why the cart must not be paid for, or null when every payment method it
|
|
1844
|
+
* offers is a test card on the Bogus gateway and all name one account. A
|
|
1845
|
+
* sandbox account on a real processor also says `test`, so `bogus_gateway`
|
|
1846
|
+
* is checked too. The same rule as the checkout load test's.
|
|
1847
|
+
*/
|
|
1848
|
+
function gatewayRefusal(cart) {
|
|
1849
|
+
if ((cart.available_payment_methods ?? []).length === 0) return refusal("NO_PAYMENT_ACCOUNT", "The cart offers nothing to pay with: the company has no payment account that takes this order. A company that signed up recently may need to confirm its signup first. Nothing was placed.");
|
|
1850
|
+
const live = liveMethodRefusal(cart);
|
|
1851
|
+
if (live) return live;
|
|
1852
|
+
if (testAccount(cart) === null) return refusal("NO_SINGLE_TEST_ACCOUNT", "The cart's test payment methods don't name one payment account to charge. Nothing was placed.");
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
1855
|
+
/** A refusal naming the first payment method that isn't a Bogus test card. */
|
|
1856
|
+
function liveMethodRefusal(cart) {
|
|
1857
|
+
const live = (cart.available_payment_methods ?? []).find((m) => m.type !== "credit_card" || m.test !== true || m.bogus_gateway !== true);
|
|
1858
|
+
if (!live) return null;
|
|
1859
|
+
return refusal("NOT_TEST_GATEWAY", `The cart offers a payment method that isn't a test card on the Bogus gateway (${live.name || live.type || "unnamed"}), so this company could be charged for real. Nothing was placed.`, { paymentMethod: {
|
|
1860
|
+
type: live.type,
|
|
1861
|
+
name: live.name
|
|
1862
|
+
} });
|
|
1863
|
+
}
|
|
1864
|
+
/** The one account every test payment method names, or null. */
|
|
1865
|
+
function testAccount(cart) {
|
|
1866
|
+
const accounts = new Set((cart.available_payment_methods ?? []).map((m) => m.uuid));
|
|
1867
|
+
const [account] = accounts;
|
|
1868
|
+
return accounts.size === 1 && typeof account === "string" && account ? account : null;
|
|
1869
|
+
}
|
|
1870
|
+
/** The code for a refused request, by the step that sent it. */
|
|
1871
|
+
function codeFor(step, status, body) {
|
|
1872
|
+
if (step === "create_cart" && status === 422 && body?.errors?.["items"]) return "VARIANT_UNAVAILABLE";
|
|
1873
|
+
if (step === "tokenize_card") return "TOKENIZE_FAILED";
|
|
1874
|
+
if (step === "complete") {
|
|
1875
|
+
const details = Array.isArray(body?.meta?.error_details) ? body.meta.error_details : [];
|
|
1876
|
+
if (status >= 500 || details.some((d) => d?.payment === "unknown")) return "ORDER_OUTCOME_UNKNOWN";
|
|
1877
|
+
if (body?.meta?.code === "cart_total_changed") return "CART_TOTAL_CHANGED";
|
|
1878
|
+
return "CHECKOUT_REFUSED";
|
|
1879
|
+
}
|
|
1880
|
+
return "REQUEST_FAILED";
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* One cart or payment call, sent without the login token. These endpoints
|
|
1884
|
+
* need none: the cart calls act on the cart token, and tokenize_card is
|
|
1885
|
+
* public. A token would do harm on one of them: for a company with isolated
|
|
1886
|
+
* payment tokens, attaching a card reads the bearer, attaches the card to
|
|
1887
|
+
* that person's customer record and rewrites the cart's email to theirs. So
|
|
1888
|
+
* these calls use a plain fetch, not the CLI's client, which always adds
|
|
1889
|
+
* Authorization.
|
|
1890
|
+
*/
|
|
1891
|
+
async function publicRequest(deps, step, method, path, body, headers = {}) {
|
|
1892
|
+
const timeout = step === "complete" ? COMPLETE_TIMEOUT_MS : CART_TIMEOUT_MS;
|
|
1893
|
+
let response;
|
|
1894
|
+
let text;
|
|
1895
|
+
try {
|
|
1896
|
+
response = await deps.fetch(`${deps.baseUrl}${path}`, {
|
|
1897
|
+
method,
|
|
1898
|
+
headers: {
|
|
1899
|
+
"Content-Type": "application/json",
|
|
1900
|
+
Accept: "application/json",
|
|
1901
|
+
...headers
|
|
1902
|
+
},
|
|
1903
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
1904
|
+
signal: AbortSignal.timeout(timeout)
|
|
1905
|
+
});
|
|
1906
|
+
text = await response.text();
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1909
|
+
if (step === "complete") throw refusal("ORDER_OUTCOME_UNKNOWN", `Checkout got no answer (${reason}), so the order may or may not have been placed. Check the orders in the admin before running this again.`);
|
|
1910
|
+
throw refusal("REQUEST_FAILED", `The ${step} request got no answer (${reason}). Nothing was charged.`);
|
|
1911
|
+
}
|
|
1912
|
+
let parsed = null;
|
|
1913
|
+
try {
|
|
1914
|
+
parsed = text ? JSON.parse(text) : null;
|
|
1915
|
+
} catch {
|
|
1916
|
+
parsed = null;
|
|
1917
|
+
}
|
|
1918
|
+
if (response.ok) return parsed ?? {};
|
|
1919
|
+
const errorBody = parsed && typeof parsed === "object" ? parsed : null;
|
|
1920
|
+
const apiMessage = typeof errorBody?.error_message === "string" ? errorBody.error_message : typeof errorBody?.error === "string" ? errorBody.error : `HTTP ${response.status}`;
|
|
1921
|
+
const code = codeFor(step, response.status, errorBody);
|
|
1922
|
+
const message = code === "ORDER_OUTCOME_UNKNOWN" ? `Checkout couldn't confirm the outcome (${apiMessage}), so the order may or may not have been placed. Check the orders in the admin before running this again.` : `${step}: ${apiMessage}`;
|
|
1923
|
+
const details = Object.fromEntries(Object.entries({
|
|
1924
|
+
step,
|
|
1925
|
+
errors: errorBody?.errors,
|
|
1926
|
+
errorDetails: errorBody?.meta?.error_details,
|
|
1927
|
+
cart: errorBody?.meta?.cart
|
|
1928
|
+
}).filter(([, v]) => v !== void 0));
|
|
1929
|
+
throw Object.assign(refusal(code, message, details), { status: response.status });
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* The buyer's email: the signed-in person's own. The order accepts the
|
|
1933
|
+
* store's required agreements in the buyer's name, so the buyer has to be
|
|
1934
|
+
* the person running the command. A company API token has no person behind
|
|
1935
|
+
* it, and `/api/me` refuses it, so a token can't place a test order.
|
|
1936
|
+
*/
|
|
1937
|
+
async function buyerEmail(reads, given) {
|
|
1938
|
+
let me;
|
|
1939
|
+
try {
|
|
1940
|
+
me = await reads.get("/api/me");
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
const status = error.status;
|
|
1943
|
+
if (status === 401 || status === 403) throw refusal("PERSON_SIGN_IN_REQUIRED", "A test order accepts the store's checkout agreements as its buyer, so it needs a person's sign-in (`fluid login` with email). A company API token has no person behind it.");
|
|
1944
|
+
throw error;
|
|
1945
|
+
}
|
|
1946
|
+
const own = me.email?.trim();
|
|
1947
|
+
if (!own) throw refusal("REQUEST_FAILED", "Couldn't read the signed-in person's email.");
|
|
1948
|
+
if (given !== void 0 && given.trim().toLowerCase() !== own.toLowerCase()) throw refusal("EMAIL_NOT_SIGNED_IN_PERSON", "--email must be your own address, the one you signed in with: the order accepts the store's agreements in its buyer's name. Leave --email out to use it.");
|
|
1949
|
+
return own;
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Place one order on the company's test gateway and return it. Every check
|
|
1953
|
+
* on the flags and the cart runs before the question, the payment checks run
|
|
1954
|
+
* again just before complete, and nothing is charged before complete: a
|
|
1955
|
+
* refused run leaves at most an unused cart. A failure after the cart exists
|
|
1956
|
+
* gives the cart's id and the time in its details.
|
|
1957
|
+
*/
|
|
1958
|
+
async function placeTestOrder(deps, opts) {
|
|
1959
|
+
if (Boolean(opts.variant) === Boolean(opts.product)) throw refusal("FLAGS_INVALID", "Pass exactly one of --variant or --product.");
|
|
1960
|
+
if (opts.email !== void 0 && !opts.email.trim()) throw refusal("FLAGS_INVALID", "--email can't be blank.");
|
|
1961
|
+
const productId = opts.product ? positiveId(opts.product, "--product") : void 0;
|
|
1962
|
+
let variantId = opts.variant ? positiveId(opts.variant, "--variant") : void 0;
|
|
1963
|
+
const email = await buyerEmail(deps.reads, opts.email);
|
|
1964
|
+
const me = await deps.reads.get("/api/company/v1/companies/me");
|
|
1965
|
+
const company = me.data?.company ?? me.company;
|
|
1966
|
+
const shop = company?.subdomain;
|
|
1967
|
+
if (!shop) throw refusal("REQUEST_FAILED", "Couldn't tell which company this token belongs to.");
|
|
1968
|
+
const country = (opts.country ?? company?.default_country?.iso ?? "").trim().toUpperCase();
|
|
1969
|
+
if (!country) throw refusal("FLAGS_INVALID", "The company has no default country. Pass --country <iso>.");
|
|
1970
|
+
const address = buildAddress(opts.address, country);
|
|
1971
|
+
let product;
|
|
1972
|
+
if (productId !== void 0) {
|
|
1973
|
+
const read = await deps.reads.get(`/api/v202604/company/products/${productId}`).catch((error) => {
|
|
1974
|
+
if (error.status === 404) throw refusal("PRODUCT_NOT_FOUND", `This company has no product ${productId}. Nothing was placed.`);
|
|
1975
|
+
throw error;
|
|
1976
|
+
});
|
|
1977
|
+
variantId = read.product?.default_variant?.id;
|
|
1978
|
+
if (!variantId) throw refusal("VARIANT_UNAVAILABLE", `Product ${productId} has no master variant to buy. Pass --variant <id> instead.`);
|
|
1979
|
+
product = {
|
|
1980
|
+
id: productId,
|
|
1981
|
+
title: read.product?.title,
|
|
1982
|
+
status: read.product?.status
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
const created = await publicRequest(deps, "create_cart", "POST", CHECKOUT, {
|
|
1986
|
+
fluid_shop: shop,
|
|
1987
|
+
country_code: country,
|
|
1988
|
+
items: [{
|
|
1989
|
+
variant_id: variantId,
|
|
1990
|
+
quantity: 1
|
|
1991
|
+
}]
|
|
1992
|
+
});
|
|
1993
|
+
const cartToken = created.cart?.cart_token;
|
|
1994
|
+
if (!cartToken) throw refusal("REQUEST_FAILED", "Checkout created no cart token.");
|
|
1995
|
+
try {
|
|
1996
|
+
return await payForCart(deps, opts, {
|
|
1997
|
+
created: created.cart ?? {},
|
|
1998
|
+
cartToken,
|
|
1999
|
+
email,
|
|
2000
|
+
shop,
|
|
2001
|
+
country,
|
|
2002
|
+
address,
|
|
2003
|
+
variantId,
|
|
2004
|
+
product
|
|
2005
|
+
});
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
throw withCartContext(err, {
|
|
2008
|
+
...created.cart?.id ? { cartId: created.cart.id } : {},
|
|
2009
|
+
at: (deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* The same failure with the cart's id and the time added to its details, so
|
|
2015
|
+
* the order can be looked up in the admin before a rerun places a second
|
|
2016
|
+
* one. The id, not the cart token: the token opens the cart (its email and
|
|
2017
|
+
* address) on the public cart routes, and stderr is often kept.
|
|
2018
|
+
*/
|
|
2019
|
+
function withCartContext(err, context) {
|
|
2020
|
+
if (!(err instanceof OnboardingError)) return err;
|
|
2021
|
+
const own = err.details;
|
|
2022
|
+
const details = own === void 0 ? context : own && typeof own === "object" && !Array.isArray(own) ? {
|
|
2023
|
+
...own,
|
|
2024
|
+
...context
|
|
2025
|
+
} : {
|
|
2026
|
+
...context,
|
|
2027
|
+
details: own
|
|
2028
|
+
};
|
|
2029
|
+
const tagged = new OnboardingError(err.message, details, err.code);
|
|
2030
|
+
const status = err.status;
|
|
2031
|
+
return status === void 0 ? tagged : Object.assign(tagged, { status });
|
|
2032
|
+
}
|
|
2033
|
+
/** Everything after the cart exists, through complete. */
|
|
2034
|
+
async function payForCart(deps, opts, run) {
|
|
2035
|
+
const { cartToken, email, shop, country, address, variantId, product } = run;
|
|
2036
|
+
const live = liveMethodRefusal(run.created);
|
|
2037
|
+
if (live) throw live;
|
|
2038
|
+
const cartPath = `${CHECKOUT}/${encodeURIComponent(cartToken)}`;
|
|
2039
|
+
await publicRequest(deps, "set_email", "PATCH", cartPath, { email });
|
|
2040
|
+
const shipping = (await publicRequest(deps, "set_address", "POST", `${cartPath}/address`, {
|
|
2041
|
+
type: "both",
|
|
2042
|
+
address
|
|
2043
|
+
})).cart?.available_shipping_methods?.[0];
|
|
2044
|
+
if (!shipping) throw refusal("NO_SHIPPING_METHOD", `The company offers no shipping method to ${country}. Nothing was placed.`);
|
|
2045
|
+
const cart = (await publicRequest(deps, "set_shipping", "PUT", `${cartPath}/shipping`, { shipping_method_id: shipping.id })).cart ?? {};
|
|
2046
|
+
if (String(cart.shipping_method?.id ?? "") !== String(shipping.id)) throw refusal("SHIPPING_NOT_SET", `Checkout didn't keep shipping method ${shipping.id}. Nothing was placed.`);
|
|
2047
|
+
const refused = gatewayRefusal(cart);
|
|
2048
|
+
if (refused) throw refused;
|
|
2049
|
+
const account = testAccount(cart);
|
|
2050
|
+
const total = cart.amount_total;
|
|
2051
|
+
const currency = cart.currency_code;
|
|
2052
|
+
if (total === null || total === void 0 || !currency) throw refusal("VARIANT_UNAVAILABLE", `Checkout shows no total or currency for this cart (an item in it may have no price in ${country}). Nothing was placed.`);
|
|
2053
|
+
const agreements = [...new Map((cart.agreements ?? []).filter((a) => a.required).map((a) => [a.id, a])).values()];
|
|
2054
|
+
const agreementIds = agreements.map((a) => a.id);
|
|
2055
|
+
const agreementVersions = agreements.filter((a) => typeof a.content_digest === "string" && a.content_digest).map((a) => ({
|
|
2056
|
+
id: a.id,
|
|
2057
|
+
content_digest: a.content_digest
|
|
2058
|
+
}));
|
|
2059
|
+
const agreementTitles = agreements.map((a) => a.title || `#${a.id}`);
|
|
2060
|
+
if (agreements.length && !opts.acceptAgreements) throw refusal("AGREEMENTS_NOT_ACCEPTED", `Checkout needs the buyer to accept the store's required agreements: ${agreementTitles.join(", ")}. The order accepts them in your name, as the signed-in person. Read them, then pass --accept-agreements. Nothing was placed.`, { agreements: agreements.map((a) => ({
|
|
2061
|
+
id: a.id,
|
|
2062
|
+
title: a.title
|
|
2063
|
+
})) });
|
|
2064
|
+
await confirmChange(`Place a test order of ${product?.title ? `"${product.title}"` : `variant ${variantId}`} for ${total} ${currency} on ${shop}? It sends a confirmation email to your sign-in address and fires order webhooks.${agreementTitles.length ? ` You accept the store's required agreements as its buyer: ${agreementTitles.join(", ")}.` : ""}`, opts.yes, deps.interactive);
|
|
2065
|
+
const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
2066
|
+
const expYear = String(now.getUTCFullYear() + 3);
|
|
2067
|
+
const issued = await publicRequest(deps, "tokenize_card", "POST", TOKENIZE_CARD, { credit_card: {
|
|
2068
|
+
first_name: "Test",
|
|
2069
|
+
last_name: "Order",
|
|
2070
|
+
number: TEST_CARD,
|
|
2071
|
+
verification_value: "123",
|
|
2072
|
+
month: "12",
|
|
2073
|
+
year: expYear
|
|
2074
|
+
} });
|
|
2075
|
+
const card = issued.transaction?.payment_method;
|
|
2076
|
+
if (issued.transaction?.succeeded !== true || card?.test !== true || !card.token) throw refusal("TOKENIZE_FAILED", "The test gateway issued no test card token. Nothing was charged.");
|
|
2077
|
+
await publicRequest(deps, "attach_card", "PUT", `${PAYMENTS}/${encodeURIComponent(cartToken)}/payment_method`, {
|
|
2078
|
+
type: "credit_card",
|
|
2079
|
+
payment_method: {
|
|
2080
|
+
token_provider: "vgs",
|
|
2081
|
+
token: card.token,
|
|
2082
|
+
card_holder: DEFAULT_NAME,
|
|
2083
|
+
last_four: card.last_four_digits || TEST_CARD.slice(-4),
|
|
2084
|
+
bin: card.first_six_digits || TEST_CARD.slice(0, 6),
|
|
2085
|
+
card_network: card.card_network || "visa",
|
|
2086
|
+
exp_month: "12",
|
|
2087
|
+
exp_year: expYear
|
|
2088
|
+
},
|
|
2089
|
+
payment_account: account
|
|
2090
|
+
});
|
|
2091
|
+
const current = (await publicRequest(deps, "reread_cart", "GET", cartPath)).cart ?? {};
|
|
2092
|
+
const moved = gatewayRefusal(current);
|
|
2093
|
+
if (moved) throw moved;
|
|
2094
|
+
if (testAccount(current) !== account || current.payment_account && current.payment_account !== account) throw refusal("PAYMENT_ACCOUNT_CHANGED", `The cart's test payment account is no longer ${account}, the one that was checked. Nothing was charged.`);
|
|
2095
|
+
const idempotencyKey = (deps.randomUUID ?? (() => crypto.randomUUID()))();
|
|
2096
|
+
const placed = await publicRequest(deps, "complete", "POST", `${cartPath}/complete`, {
|
|
2097
|
+
expected_total: String(total),
|
|
2098
|
+
expected_currency: currency,
|
|
2099
|
+
...agreementIds.length ? { accepted_agreement_ids: agreementIds } : {},
|
|
2100
|
+
...agreementVersions.length ? { accepted_agreement_versions: agreementVersions } : {}
|
|
2101
|
+
}, { "Idempotency-Key": idempotencyKey });
|
|
2102
|
+
const order = placed.order;
|
|
2103
|
+
if (!order?.id) throw refusal("UNEXPECTED_CHECKOUT_RESPONSE", "Checkout answered without an order (it may want a redirect, a 3DS challenge or an enrollment step). Check the orders in the admin before running this again.", { keys: Object.keys(placed) });
|
|
2104
|
+
return {
|
|
2105
|
+
placed: true,
|
|
2106
|
+
order: {
|
|
2107
|
+
id: order.id,
|
|
2108
|
+
orderNumber: order.order_number
|
|
2109
|
+
},
|
|
2110
|
+
total,
|
|
2111
|
+
currency,
|
|
2112
|
+
shop,
|
|
2113
|
+
country,
|
|
2114
|
+
variant: {
|
|
2115
|
+
id: variantId,
|
|
2116
|
+
...product ? {
|
|
2117
|
+
productId: product.id,
|
|
2118
|
+
productStatus: product.status
|
|
2119
|
+
} : {}
|
|
2120
|
+
},
|
|
2121
|
+
paymentAccount: account,
|
|
2122
|
+
acceptedAgreements: agreements.map((a) => ({
|
|
2123
|
+
id: a.id,
|
|
2124
|
+
title: a.title
|
|
2125
|
+
})),
|
|
2126
|
+
note: `A real order on the test gateway: nothing was charged, but a confirmation email went to your sign-in address and order webhooks fired. Refund and cancel it in the admin if you don't want it in the order list.`
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
const commandBaseUrl = (parent) => (parent.opts().baseUrl ?? process.env["FLUID_API_BASE"] ?? "https://api.fluid.app").replace(/\/$/, "");
|
|
2130
|
+
function registerTestOrderCommand(parent, ctx, overrides = {}) {
|
|
2131
|
+
parent.command("test-order").description("Place one order on the company's test gateway to prove checkout works, as the signed-in person; refused unless every payment method is a test card (places a real order: sends an email, fires webhooks)").option("--email <email>", "The buyer's email; must be your own (the signed-in person's), which is the default").option("--accept-agreements", "Accept the store's required checkout agreements as the buyer (required when it has any)", false).option("--variant <id>", "The variant to buy").option("--product <id>", "A product to buy, by its master variant").option("--country <iso>", "Country to buy in (ISO code); defaults to the company's default country").option("--address <json>", "Ship-to address as JSON, e.g. {\"address1\":\"…\",\"city\":\"…\",\"state\":\"…\",\"postal_code\":\"…\"}; required outside the US").option("--yes", "Confirm without a prompt", false).action((opts) => run(async () => {
|
|
2132
|
+
const reads = await ctx.getClient();
|
|
2133
|
+
ctx.output(await placeTestOrder({
|
|
2134
|
+
reads,
|
|
2135
|
+
fetch: overrides.fetch ?? ((input, init) => fetch(input, init)),
|
|
2136
|
+
baseUrl: commandBaseUrl(parent),
|
|
2137
|
+
interactive: overrides.interactive
|
|
2138
|
+
}, opts));
|
|
2139
|
+
}));
|
|
2140
|
+
}
|
|
2141
|
+
//#endregion
|
|
2142
|
+
//#region src/index.ts
|
|
2143
|
+
async function client(ctx) {
|
|
2144
|
+
return await ctx.getClient();
|
|
2145
|
+
}
|
|
1770
2146
|
const collect = (value, previous = []) => [...previous, value];
|
|
1771
2147
|
const GROUPS = [
|
|
1772
2148
|
"info",
|
|
@@ -1913,10 +2289,13 @@ const plugin = {
|
|
|
1913
2289
|
name: "@fluid-app/fluid-cli-payments",
|
|
1914
2290
|
version: "0.1.0",
|
|
1915
2291
|
register(ctx) {
|
|
1916
|
-
ctx.program.addCommand(createDomainCommand("payments", "Payments onboarding: what's missing, fill it in, and submit",
|
|
2292
|
+
ctx.program.addCommand(createDomainCommand("payments", "Payments onboarding: what's missing, fill it in, and submit; or place a test order", (parent, commandCtx) => {
|
|
2293
|
+
registerOnboardingCommands(parent, commandCtx);
|
|
2294
|
+
registerTestOrderCommand(parent, commandCtx);
|
|
2295
|
+
}));
|
|
1917
2296
|
}
|
|
1918
2297
|
};
|
|
1919
2298
|
//#endregion
|
|
1920
|
-
export {
|
|
2299
|
+
export { plugin as default, registerOnboardingCommands };
|
|
1921
2300
|
|
|
1922
2301
|
//# sourceMappingURL=index.mjs.map
|