@firedrill-tools/stripe 0.1.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.
- package/LICENSE +201 -0
- package/README.md +433 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-unavailable.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/refund-committed-lost.scenario.json +11 -0
- package/firedrill/stripe-api-unavailable.drill.json +463 -0
- package/firedrill/stripe-denied.drill.json +74 -0
- package/firedrill/stripe-large-pages.drill.json +153 -0
- package/firedrill/stripe-live-mode.drill.json +136 -0
- package/firedrill/stripe-mcp-aliases.drill.json +406 -0
- package/firedrill/stripe-no-permissions.drill.json +1143 -0
- package/firedrill/stripe-rate-limited.drill.json +616 -0
- package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
- package/firedrill/stripe-rest-flow.drill.json +1401 -0
- package/firedrill/stripe-restricted-key.drill.json +171 -0
- package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
- package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/app.js +456 -0
- package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/icons.js +90 -0
- package/firedrill/tools/stripe/app/site/index.html +137 -0
- package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
- package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
- package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
- package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
- package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
- package/firedrill/tools/stripe/app/site/store.js +99 -0
- package/firedrill/tools/stripe/app/site/styles.css +2512 -0
- package/firedrill/tools/stripe/app/site/ui.js +767 -0
- package/firedrill/tools/stripe/app/site/widgets.js +707 -0
- package/firedrill/tools/stripe/behavior.mjs +148 -0
- package/firedrill/tools/stripe/lib/cards.mjs +53 -0
- package/firedrill/tools/stripe/lib/form.mjs +204 -0
- package/firedrill/tools/stripe/lib/ids.mjs +85 -0
- package/firedrill/tools/stripe/lib/money.mjs +35 -0
- package/firedrill/tools/stripe/lib/objects.mjs +229 -0
- package/firedrill/tools/stripe/lib/periods.mjs +41 -0
- package/firedrill/tools/stripe/lib/size.mjs +55 -0
- package/firedrill/tools/stripe/lib/state.mjs +230 -0
- package/firedrill/tools/stripe/lib/validate.mjs +184 -0
- package/firedrill/tools/stripe/lib/wire.mjs +98 -0
- package/firedrill/tools/stripe/ops/billing.mjs +914 -0
- package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
- package/firedrill/tools/stripe/ops/customers.mjs +241 -0
- package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
- package/firedrill/tools/stripe/ops/payments.mjs +608 -0
- package/firedrill/tools/stripe/stripe.tool.json +28833 -0
- package/firedrill/world.json +8527 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +7999 -0
- package/test/conformance.mjs +1133 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Products and prices. Prices are immutable in this Tool (no `prices.update`); `lookup_key` is unique among
|
|
2
|
+
// active prices as in Stripe.
|
|
3
|
+
|
|
4
|
+
import { nextId } from "../lib/ids.mjs";
|
|
5
|
+
import { decimalString } from "../lib/money.mjs";
|
|
6
|
+
import { applyExpand, makeView, renderPrice, renderProduct, validateExpand } from "../lib/objects.mjs";
|
|
7
|
+
import { INTERVALS, MAX_INTERVAL_COUNT } from "../lib/periods.mjs";
|
|
8
|
+
import { allRows, getRow, invalid, matchesRange, paginate, parameterMissing, parameterUnknown, requirePermission, requireRow, resourceMissing } from "../lib/state.mjs";
|
|
9
|
+
import { isPlainObject, mergeMetadata, optionalBoolean, optionalEnum, optionalInteger, optionalString, optionalStringArray, rejectMangled, requireCurrency, requireInteger, requireString } from "../lib/validate.mjs";
|
|
10
|
+
|
|
11
|
+
const TAX_BEHAVIOR = ["unspecified", "inclusive", "exclusive"];
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------------------------
|
|
14
|
+
// Products
|
|
15
|
+
// ---------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function productFields(context, input, current) {
|
|
18
|
+
return {
|
|
19
|
+
name: current === null ? requireString(context, input, "name", 250) : input.name === undefined ? current.name : requireString(context, input, "name", 250),
|
|
20
|
+
description: optionalString(context, input, "description", current === null ? null : current.description, 40_000),
|
|
21
|
+
active: optionalBoolean(context, input, "active", current === null ? true : current.active),
|
|
22
|
+
metadata: mergeMetadata(context, input.metadata, current === null ? {} : current.metadata),
|
|
23
|
+
unit_label: optionalString(context, input, "unit_label", current === null ? null : current.unit_label, 12),
|
|
24
|
+
images: optionalStringArray(context, input, "images", current === null ? [] : current.images),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function productsCreate(input, context) {
|
|
29
|
+
requirePermission(context, "products", "write");
|
|
30
|
+
const view = makeView(context);
|
|
31
|
+
const expand = validateExpand(context, input.expand, "product");
|
|
32
|
+
const fields = productFields(context, input, null);
|
|
33
|
+
if (fields.images.length > 8) return invalid(context, "You can specify up to 8 images.", "parameter_invalid", "images");
|
|
34
|
+
if (input.default_price !== undefined) {
|
|
35
|
+
return invalid(context, "default_price cannot be set when creating a product because no price of the product exists yet; create the price, then update the product.", "parameter_invalid", "default_price");
|
|
36
|
+
}
|
|
37
|
+
const id = nextId(context, "prod");
|
|
38
|
+
const product = {
|
|
39
|
+
id,
|
|
40
|
+
object: "product",
|
|
41
|
+
active: fields.active,
|
|
42
|
+
created: view.now,
|
|
43
|
+
default_price: null,
|
|
44
|
+
description: fields.description,
|
|
45
|
+
images: fields.images,
|
|
46
|
+
livemode: view.livemode,
|
|
47
|
+
marketing_features: [],
|
|
48
|
+
metadata: fields.metadata,
|
|
49
|
+
name: fields.name,
|
|
50
|
+
package_dimensions: null,
|
|
51
|
+
shippable: null,
|
|
52
|
+
statement_descriptor: null,
|
|
53
|
+
tax_code: null,
|
|
54
|
+
type: "service",
|
|
55
|
+
unit_label: fields.unit_label,
|
|
56
|
+
updated: view.now,
|
|
57
|
+
url: null,
|
|
58
|
+
};
|
|
59
|
+
context.state.put("products", id, product);
|
|
60
|
+
return applyExpand(view, renderProduct(view, product), expand, "product");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function productsRetrieve(input, context) {
|
|
64
|
+
requirePermission(context, "products", "read");
|
|
65
|
+
const view = makeView(context);
|
|
66
|
+
const expand = validateExpand(context, input.expand, "product");
|
|
67
|
+
const product = requireRow(context, "products", input.product, "product");
|
|
68
|
+
return applyExpand(view, renderProduct(view, product), expand, "product");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function productsUpdate(input, context) {
|
|
72
|
+
requirePermission(context, "products", "write");
|
|
73
|
+
const view = makeView(context);
|
|
74
|
+
const expand = validateExpand(context, input.expand, "product");
|
|
75
|
+
const current = requireRow(context, "products", input.product, "product");
|
|
76
|
+
const fields = productFields(context, input, current);
|
|
77
|
+
if (fields.images.length > 8) return invalid(context, "You can specify up to 8 images.", "parameter_invalid", "images");
|
|
78
|
+
let defaultPrice = current.default_price;
|
|
79
|
+
if (input.default_price !== undefined) {
|
|
80
|
+
if (input.default_price === "" || input.default_price === null) defaultPrice = null;
|
|
81
|
+
else {
|
|
82
|
+
const price = getRow(context, "prices", input.default_price);
|
|
83
|
+
if (price === null || price.product !== current.id) {
|
|
84
|
+
return invalid(context, `The price '${String(input.default_price)}' does not belong to product '${current.id}'.`, "parameter_invalid", "default_price");
|
|
85
|
+
}
|
|
86
|
+
if (!price.active) return invalid(context, `The price '${price.id}' is not active and cannot be the default price.`, "parameter_invalid", "default_price");
|
|
87
|
+
defaultPrice = price.id;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const product = { ...current, ...fields, default_price: defaultPrice, updated: view.now };
|
|
91
|
+
context.state.put("products", product.id, product);
|
|
92
|
+
return applyExpand(view, renderProduct(view, product), expand, "product");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function productsList(input, context) {
|
|
96
|
+
requirePermission(context, "products", "read");
|
|
97
|
+
rejectMangled(context, input, ["ids"]);
|
|
98
|
+
const view = makeView(context);
|
|
99
|
+
const expand = validateExpand(context, input.expand, "product", true);
|
|
100
|
+
const active = optionalBoolean(context, input, "active", undefined);
|
|
101
|
+
const ids = optionalStringArray(context, input, "ids", undefined);
|
|
102
|
+
const rows = allRows(context, "products").filter(
|
|
103
|
+
(product) => (active === undefined || product.active === active) && (ids === undefined || ids.includes(product.id)) && matchesRange(product.created, input.created, context, "created"),
|
|
104
|
+
);
|
|
105
|
+
return paginate(context, "products", rows, input, "/v1/products", (row) => applyExpand(view, renderProduct(view, row), expand, "product"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
// Prices
|
|
110
|
+
// ---------------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function recurringInput(context, value) {
|
|
113
|
+
if (value === undefined) return null;
|
|
114
|
+
if (!isPlainObject(value)) return invalid(context, "Invalid recurring: must be a hash.", "parameter_invalid", "recurring");
|
|
115
|
+
for (const key of Object.keys(value)) {
|
|
116
|
+
if (!["interval", "interval_count", "trial_period_days"].includes(key)) return parameterUnknown(context, `recurring[${key}]`);
|
|
117
|
+
}
|
|
118
|
+
if (value.interval === undefined) return parameterMissing(context, "recurring[interval]");
|
|
119
|
+
if (!INTERVALS.includes(value.interval)) return invalid(context, `Invalid recurring[interval]: must be one of ${INTERVALS.join(", ")}`, "parameter_invalid", "recurring[interval]");
|
|
120
|
+
const count = optionalInteger(context, value, "interval_count", 1, { min: 1 });
|
|
121
|
+
if (count > MAX_INTERVAL_COUNT[value.interval]) {
|
|
122
|
+
return invalid(context, `The maximum billing interval is one year; recurring[interval_count] for '${value.interval}' must be at most ${MAX_INTERVAL_COUNT[value.interval]}.`, "parameter_invalid", "recurring[interval_count]");
|
|
123
|
+
}
|
|
124
|
+
const trial = optionalInteger(context, value, "trial_period_days", null, { min: 1, max: 730 });
|
|
125
|
+
return { interval: value.interval, interval_count: count, meter: null, trial_period_days: trial, usage_type: "licensed" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function pricesCreate(input, context) {
|
|
129
|
+
requirePermission(context, "products", "write");
|
|
130
|
+
const view = makeView(context);
|
|
131
|
+
const expand = validateExpand(context, input.expand, "price");
|
|
132
|
+
const productId = requireString(context, input, "product", 255);
|
|
133
|
+
const product = getRow(context, "products", productId);
|
|
134
|
+
if (product === null) return resourceMissing(context, "products", productId, "product");
|
|
135
|
+
const currency = requireCurrency(context, input.currency);
|
|
136
|
+
const unitAmount = requireInteger(context, input, "unit_amount", { min: 0, max: 99_999_999 });
|
|
137
|
+
const recurring = recurringInput(context, input.recurring);
|
|
138
|
+
const nickname = optionalString(context, input, "nickname", null, 250);
|
|
139
|
+
const lookupKey = optionalString(context, input, "lookup_key", null, 200);
|
|
140
|
+
const active = optionalBoolean(context, input, "active", true);
|
|
141
|
+
const metadata = mergeMetadata(context, input.metadata);
|
|
142
|
+
const taxBehavior = optionalEnum(context, input, "tax_behavior", TAX_BEHAVIOR, "unspecified");
|
|
143
|
+
if (lookupKey !== null && active) {
|
|
144
|
+
const clash = allRows(context, "prices").find((price) => price.active && price.lookup_key === lookupKey);
|
|
145
|
+
if (clash !== undefined) {
|
|
146
|
+
return invalid(context, `An active price with lookup_key '${lookupKey}' already exists (${clash.id}). Pass transfer_lookup_key=true to move it, or choose another key.`, "resource_already_exists", "lookup_key");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const id = nextId(context, "price");
|
|
150
|
+
const price = {
|
|
151
|
+
id,
|
|
152
|
+
object: "price",
|
|
153
|
+
active,
|
|
154
|
+
billing_scheme: "per_unit",
|
|
155
|
+
created: view.now,
|
|
156
|
+
currency,
|
|
157
|
+
custom_unit_amount: null,
|
|
158
|
+
livemode: view.livemode,
|
|
159
|
+
lookup_key: lookupKey,
|
|
160
|
+
metadata,
|
|
161
|
+
nickname,
|
|
162
|
+
product: product.id,
|
|
163
|
+
recurring,
|
|
164
|
+
tax_behavior: taxBehavior,
|
|
165
|
+
tiers_mode: null,
|
|
166
|
+
transform_quantity: null,
|
|
167
|
+
type: recurring === null ? "one_time" : "recurring",
|
|
168
|
+
unit_amount: unitAmount,
|
|
169
|
+
unit_amount_decimal: decimalString(unitAmount),
|
|
170
|
+
};
|
|
171
|
+
context.state.put("prices", id, price);
|
|
172
|
+
return applyExpand(view, renderPrice(view, price), expand, "price");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function pricesRetrieve(input, context) {
|
|
176
|
+
requirePermission(context, "products", "read");
|
|
177
|
+
const view = makeView(context);
|
|
178
|
+
const expand = validateExpand(context, input.expand, "price");
|
|
179
|
+
const price = requireRow(context, "prices", input.price, "price");
|
|
180
|
+
return applyExpand(view, renderPrice(view, price), expand, "price");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function pricesList(input, context) {
|
|
184
|
+
requirePermission(context, "products", "read");
|
|
185
|
+
rejectMangled(context, input, ["product", "lookup_keys", "currency", "type"]);
|
|
186
|
+
const view = makeView(context);
|
|
187
|
+
const expand = validateExpand(context, input.expand, "price", true);
|
|
188
|
+
const product = optionalString(context, input, "product", undefined, 255);
|
|
189
|
+
const active = optionalBoolean(context, input, "active", undefined);
|
|
190
|
+
const type = optionalEnum(context, input, "type", ["one_time", "recurring"], undefined);
|
|
191
|
+
const currency = input.currency === undefined ? undefined : requireCurrency(context, input.currency);
|
|
192
|
+
const lookupKeys = optionalStringArray(context, input, "lookup_keys", undefined);
|
|
193
|
+
const rows = allRows(context, "prices").filter(
|
|
194
|
+
(price) =>
|
|
195
|
+
(product === undefined || price.product === product) &&
|
|
196
|
+
(active === undefined || price.active === active) &&
|
|
197
|
+
(type === undefined || price.type === type) &&
|
|
198
|
+
(currency === undefined || price.currency === currency) &&
|
|
199
|
+
(lookupKeys === undefined || (price.lookup_key !== null && lookupKeys.includes(price.lookup_key))) &&
|
|
200
|
+
matchesRange(price.created, input.created, context, "created"),
|
|
201
|
+
);
|
|
202
|
+
return paginate(context, "prices", rows, input, "/v1/prices", (row) => applyExpand(view, renderPrice(view, row), expand, "price"));
|
|
203
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Customers and payment methods. Cards are synthetic: Stripe's published test ids materialise real
|
|
2
|
+
// `payment_methods` rows when attached or confirmed, exactly as Stripe's test mode does.
|
|
3
|
+
|
|
4
|
+
import { cardDetails, testCard } from "../lib/cards.mjs";
|
|
5
|
+
import { invoicePrefix, nextId, nextSequence, renderId } from "../lib/ids.mjs";
|
|
6
|
+
import { applyExpand, makeView, renderCustomer, renderPaymentMethod, validateExpand } from "../lib/objects.mjs";
|
|
7
|
+
import { allRows, getRow, invalidState, matchesRange, paginate, parameterMissing, parameterUnknown, requirePermission, requirePermissions, requireRow, resourceMissing } from "../lib/state.mjs";
|
|
8
|
+
import { isPlainObject, mergeMetadata, normalizeAddress, normalizeShipping, optionalEnum, optionalString, optionalStringArray, rejectMangled, validateEmail } from "../lib/validate.mjs";
|
|
9
|
+
|
|
10
|
+
const TAX_EXEMPT = ["none", "exempt", "reverse"];
|
|
11
|
+
const EMPTY_ADDRESS = Object.freeze({ city: null, country: null, line1: null, line2: null, postal_code: null, state: null });
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------------------------
|
|
14
|
+
// Payment method helpers (shared with payments and billing)
|
|
15
|
+
// ---------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A test id (`pm_card_*`) → `{ card }`; a materialised row → `{ row }`; anything else fails `RESOURCE_MISSING`. */
|
|
18
|
+
export function resolvePaymentMethod(context, view, id, param = "payment_method") {
|
|
19
|
+
if (typeof id !== "string" || id.length === 0) return parameterMissing(context, param);
|
|
20
|
+
const card = testCard(id);
|
|
21
|
+
if (card !== undefined) {
|
|
22
|
+
if (view.livemode) return resourceMissing(context, "payment_methods", id, param);
|
|
23
|
+
return { card, testId: id };
|
|
24
|
+
}
|
|
25
|
+
const row = getRow(context, "payment_methods", id);
|
|
26
|
+
if (row === null) return resourceMissing(context, "payment_methods", id, param);
|
|
27
|
+
return { row };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function billingDetails(customer) {
|
|
31
|
+
if (customer === null) return { address: { ...EMPTY_ADDRESS }, email: null, name: null, phone: null };
|
|
32
|
+
return { address: customer.address === null ? { ...EMPTY_ADDRESS } : { ...customer.address }, email: customer.email, name: customer.name, phone: customer.phone };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Create a `payment_methods` row for a catalogue card, optionally attached to `customer` (a row or null). */
|
|
36
|
+
export function materializeCard(context, view, card, customer) {
|
|
37
|
+
const id = nextId(context, "pm");
|
|
38
|
+
const row = {
|
|
39
|
+
id,
|
|
40
|
+
object: "payment_method",
|
|
41
|
+
allow_redisplay: "unspecified",
|
|
42
|
+
billing_details: billingDetails(customer),
|
|
43
|
+
card: cardDetails(card),
|
|
44
|
+
created: view.now,
|
|
45
|
+
customer: customer === null ? null : customer.id,
|
|
46
|
+
livemode: view.livemode,
|
|
47
|
+
metadata: {},
|
|
48
|
+
type: "card",
|
|
49
|
+
outcome: card.outcome,
|
|
50
|
+
};
|
|
51
|
+
context.state.put("payment_methods", id, row);
|
|
52
|
+
return row;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Attach a resolved method to a customer, materialising test cards; fails when it belongs to someone. */
|
|
56
|
+
export function attachMethod(context, view, resolved, customer) {
|
|
57
|
+
if (resolved.card !== undefined) return materializeCard(context, view, resolved.card, customer);
|
|
58
|
+
if (resolved.row.customer !== null) {
|
|
59
|
+
return invalidState(context, "The payment method you provided has already been attached to a customer.", "payment_method_already_attached");
|
|
60
|
+
}
|
|
61
|
+
const row = { ...resolved.row, customer: customer.id, billing_details: billingDetails(customer) };
|
|
62
|
+
context.state.put("payment_methods", row.id, row);
|
|
63
|
+
return row;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve an id that must name a method attached to `customer` (for defaults); returns the row. */
|
|
67
|
+
export function requireAttachedMethod(context, view, id, customer, param) {
|
|
68
|
+
const resolved = resolvePaymentMethod(context, view, id, param);
|
|
69
|
+
if (resolved.card !== undefined || resolved.row.customer !== customer.id) {
|
|
70
|
+
return invalidState(context, `The payment method '${id}' is not attached to customer '${customer.id}'. Attach it first with /v1/payment_methods/:id/attach.`, "payment_method_unattached");
|
|
71
|
+
}
|
|
72
|
+
return resolved.row;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------------------------
|
|
76
|
+
// Customers
|
|
77
|
+
// ---------------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function newCustomerRow(view, sequence, fields) {
|
|
80
|
+
return {
|
|
81
|
+
id: renderId("cus", sequence),
|
|
82
|
+
object: "customer",
|
|
83
|
+
address: fields.address,
|
|
84
|
+
balance: 0,
|
|
85
|
+
created: view.now,
|
|
86
|
+
currency: null,
|
|
87
|
+
default_source: null,
|
|
88
|
+
delinquent: false,
|
|
89
|
+
description: fields.description,
|
|
90
|
+
email: fields.email,
|
|
91
|
+
invoice_prefix: invoicePrefix(sequence),
|
|
92
|
+
invoice_settings: { custom_fields: null, default_payment_method: null, footer: null, rendering_options: null },
|
|
93
|
+
livemode: view.livemode,
|
|
94
|
+
metadata: fields.metadata,
|
|
95
|
+
name: fields.name,
|
|
96
|
+
next_invoice_sequence: 1,
|
|
97
|
+
phone: fields.phone,
|
|
98
|
+
preferred_locales: fields.preferred_locales,
|
|
99
|
+
shipping: fields.shipping,
|
|
100
|
+
tax_exempt: fields.tax_exempt,
|
|
101
|
+
test_clock: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invoiceSettingsInput(context, input) {
|
|
106
|
+
const settings = input.invoice_settings;
|
|
107
|
+
if (settings === undefined) return undefined;
|
|
108
|
+
if (!isPlainObject(settings)) return parameterUnknown(context, "invoice_settings");
|
|
109
|
+
for (const key of Object.keys(settings)) if (key !== "default_payment_method") return parameterUnknown(context, `invoice_settings[${key}]`);
|
|
110
|
+
return settings.default_payment_method;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function customersCreate(input, context) {
|
|
114
|
+
requirePermission(context, "customers", "write");
|
|
115
|
+
const view = makeView(context);
|
|
116
|
+
const expand = validateExpand(context, input.expand, "customer");
|
|
117
|
+
const fields = {
|
|
118
|
+
name: optionalString(context, input, "name", null, 256),
|
|
119
|
+
email: validateEmail(context, optionalString(context, input, "email", null, 512)),
|
|
120
|
+
description: optionalString(context, input, "description", null),
|
|
121
|
+
phone: optionalString(context, input, "phone", null, 20),
|
|
122
|
+
address: normalizeAddress(context, input.address),
|
|
123
|
+
shipping: normalizeShipping(context, input.shipping),
|
|
124
|
+
metadata: mergeMetadata(context, input.metadata),
|
|
125
|
+
preferred_locales: optionalStringArray(context, input, "preferred_locales", []),
|
|
126
|
+
tax_exempt: optionalEnum(context, input, "tax_exempt", TAX_EXEMPT, "none"),
|
|
127
|
+
};
|
|
128
|
+
const defaultMethod = invoiceSettingsInput(context, input);
|
|
129
|
+
const sequence = nextSequence(context);
|
|
130
|
+
const customer = newCustomerRow(view, sequence, fields);
|
|
131
|
+
if (input.payment_method !== undefined) {
|
|
132
|
+
const resolved = resolvePaymentMethod(context, view, input.payment_method);
|
|
133
|
+
if (defaultMethod !== undefined && defaultMethod !== "" && defaultMethod !== input.payment_method) {
|
|
134
|
+
return invalidState(context, `The payment method '${defaultMethod}' is not attached to this customer.`, "payment_method_unattached");
|
|
135
|
+
}
|
|
136
|
+
const method = attachMethod(context, view, resolved, customer);
|
|
137
|
+
customer.invoice_settings.default_payment_method = method.id;
|
|
138
|
+
if (method.billing_details.name === null && method.billing_details.email === null) {
|
|
139
|
+
context.state.put("payment_methods", method.id, { ...method, billing_details: billingDetails(customer) });
|
|
140
|
+
}
|
|
141
|
+
} else if (defaultMethod !== undefined && defaultMethod !== "") {
|
|
142
|
+
return invalidState(context, `The payment method '${defaultMethod}' is not attached to this customer.`, "payment_method_unattached");
|
|
143
|
+
}
|
|
144
|
+
context.state.put("customers", customer.id, customer);
|
|
145
|
+
return applyExpand(view, renderCustomer(view, customer), expand, "customer");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function customersRetrieve(input, context) {
|
|
149
|
+
requirePermission(context, "customers", "read");
|
|
150
|
+
const view = makeView(context);
|
|
151
|
+
const expand = validateExpand(context, input.expand, "customer");
|
|
152
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
153
|
+
return applyExpand(view, renderCustomer(view, customer), expand, "customer");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function customersUpdate(input, context) {
|
|
157
|
+
requirePermission(context, "customers", "write");
|
|
158
|
+
const view = makeView(context);
|
|
159
|
+
const expand = validateExpand(context, input.expand, "customer");
|
|
160
|
+
const current = requireRow(context, "customers", input.customer, "customer");
|
|
161
|
+
const next = {
|
|
162
|
+
...current,
|
|
163
|
+
name: optionalString(context, input, "name", current.name, 256),
|
|
164
|
+
email: validateEmail(context, optionalString(context, input, "email", current.email, 512)),
|
|
165
|
+
description: optionalString(context, input, "description", current.description),
|
|
166
|
+
phone: optionalString(context, input, "phone", current.phone, 20),
|
|
167
|
+
address: normalizeAddress(context, input.address, "address", current.address),
|
|
168
|
+
shipping: normalizeShipping(context, input.shipping, current.shipping),
|
|
169
|
+
metadata: mergeMetadata(context, input.metadata, current.metadata),
|
|
170
|
+
preferred_locales: optionalStringArray(context, input, "preferred_locales", current.preferred_locales),
|
|
171
|
+
tax_exempt: optionalEnum(context, input, "tax_exempt", TAX_EXEMPT, current.tax_exempt),
|
|
172
|
+
invoice_settings: { ...current.invoice_settings },
|
|
173
|
+
};
|
|
174
|
+
const defaultMethod = invoiceSettingsInput(context, input);
|
|
175
|
+
if (defaultMethod !== undefined) {
|
|
176
|
+
if (defaultMethod === "" || defaultMethod === null) next.invoice_settings.default_payment_method = null;
|
|
177
|
+
else next.invoice_settings.default_payment_method = requireAttachedMethod(context, view, defaultMethod, current, "invoice_settings[default_payment_method]").id;
|
|
178
|
+
}
|
|
179
|
+
next.delinquent = renderCustomer(view, current).delinquent;
|
|
180
|
+
context.state.put("customers", next.id, next);
|
|
181
|
+
return applyExpand(view, renderCustomer(view, next), expand, "customer");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function customersList(input, context) {
|
|
185
|
+
requirePermission(context, "customers", "read");
|
|
186
|
+
rejectMangled(context, input, ["email"]);
|
|
187
|
+
const view = makeView(context);
|
|
188
|
+
const expand = validateExpand(context, input.expand, "customer", true);
|
|
189
|
+
const email = optionalString(context, input, "email", undefined, 512);
|
|
190
|
+
const rows = allRows(context, "customers").filter(
|
|
191
|
+
(customer) => (email === undefined || email === null || customer.email === email) && matchesRange(customer.created, input.created, context, "created"),
|
|
192
|
+
);
|
|
193
|
+
return paginate(context, "customers", rows, input, "/v1/customers", (row) => applyExpand(view, renderCustomer(view, row), expand, "customer"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------------------------
|
|
197
|
+
// Payment methods
|
|
198
|
+
// ---------------------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export function paymentMethodsList(input, context) {
|
|
201
|
+
requirePermission(context, "payment_methods", "read");
|
|
202
|
+
rejectMangled(context, input, ["customer", "type"]);
|
|
203
|
+
const view = makeView(context);
|
|
204
|
+
const expand = validateExpand(context, input.expand, "payment_method", true);
|
|
205
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
206
|
+
const type = optionalString(context, input, "type", undefined, 64);
|
|
207
|
+
const rows = type !== undefined && type !== "card" ? [] : allRows(context, "payment_methods").filter((method) => method.customer === customer.id);
|
|
208
|
+
return paginate(context, "payment_methods", rows, input, "/v1/payment_methods", (row) => applyExpand(view, renderPaymentMethod(view, row), expand, "payment_method"));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function paymentMethodsAttach(input, context) {
|
|
212
|
+
requirePermissions(context, [["payment_methods", "write"], ["customers", "read"]]);
|
|
213
|
+
const view = makeView(context);
|
|
214
|
+
const expand = validateExpand(context, input.expand, "payment_method");
|
|
215
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
216
|
+
const resolved = resolvePaymentMethod(context, view, input.payment_method);
|
|
217
|
+
const method = attachMethod(context, view, resolved, customer);
|
|
218
|
+
return applyExpand(view, renderPaymentMethod(view, method), expand, "payment_method");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function paymentMethodsDetach(input, context) {
|
|
222
|
+
requirePermission(context, "payment_methods", "write");
|
|
223
|
+
const view = makeView(context);
|
|
224
|
+
const expand = validateExpand(context, input.expand, "payment_method");
|
|
225
|
+
const method = requireRow(context, "payment_methods", input.payment_method, "payment_method");
|
|
226
|
+
if (method.customer === null) {
|
|
227
|
+
return invalidState(context, "A payment method cannot be detached because it is not attached to a customer.", "payment_method_unattached");
|
|
228
|
+
}
|
|
229
|
+
const customer = getRow(context, "customers", method.customer);
|
|
230
|
+
if (customer !== null && customer.invoice_settings.default_payment_method === method.id) {
|
|
231
|
+
context.state.put("customers", customer.id, { ...customer, invoice_settings: { ...customer.invoice_settings, default_payment_method: null } });
|
|
232
|
+
}
|
|
233
|
+
for (const subscription of allRows(context, "subscriptions")) {
|
|
234
|
+
if (subscription.default_payment_method === method.id && subscription.status !== "canceled") {
|
|
235
|
+
context.state.put("subscriptions", subscription.id, { ...subscription, default_payment_method: null });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const detached = { ...method, customer: null, billing_details: billingDetails(null) };
|
|
239
|
+
context.state.put("payment_methods", detached.id, detached);
|
|
240
|
+
return applyExpand(view, renderPaymentMethod(view, detached), expand, "payment_method");
|
|
241
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Dashboard context for the browser app: the simulated account, the world's virtual time, the caller's mode and
|
|
2
|
+
// restricted-key permission levels. Read-only, never fails, and reachable only as the canonical operation
|
|
3
|
+
// (there is no Stripe REST route for it); the app uses it so "today" never comes from the browser clock.
|
|
4
|
+
|
|
5
|
+
import { PERMISSION_GROUPS, nowSeconds, permissionLevel, livemode } from "../lib/state.mjs";
|
|
6
|
+
import { API_VERSION } from "../lib/wire.mjs";
|
|
7
|
+
import { accountInfo } from "./payments.mjs";
|
|
8
|
+
|
|
9
|
+
export function dashboardContext(input, context) {
|
|
10
|
+
void input;
|
|
11
|
+
const permissions = {};
|
|
12
|
+
for (const group of PERMISSION_GROUPS) permissions[group] = permissionLevel(context, group);
|
|
13
|
+
const account = accountInfo(context);
|
|
14
|
+
return {
|
|
15
|
+
object: "dashboard.context",
|
|
16
|
+
account: {
|
|
17
|
+
id: account.id,
|
|
18
|
+
business_name: account.business_name,
|
|
19
|
+
country: account.country,
|
|
20
|
+
default_currency: account.default_currency,
|
|
21
|
+
statement_descriptor: account.statement_descriptor,
|
|
22
|
+
support_email: account.support_email,
|
|
23
|
+
},
|
|
24
|
+
now: nowSeconds(context),
|
|
25
|
+
livemode: livemode(context),
|
|
26
|
+
permissions,
|
|
27
|
+
api_version: API_VERSION,
|
|
28
|
+
};
|
|
29
|
+
}
|