@blamejs/blamejs-shop 0.0.56 → 0.0.57
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/CHANGELOG.md +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.paymentMethods
|
|
4
|
+
* @title Payment methods primitive — per-customer saved processor tokens
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Customers save a reference to a payment instrument so they don't
|
|
8
|
+
* re-enter card details on every order. The shop NEVER touches the
|
|
9
|
+
* raw PAN or CVV — those stay inside the payment processor's
|
|
10
|
+
* PCI-DSS scope. What we hold is the processor's opaque token
|
|
11
|
+
* (Stripe `pm_…`, PayPal billing-agreement id, Square card id,
|
|
12
|
+
* Braintree payment-method nonce, Authorize.Net customer-payment
|
|
13
|
+
* profile id) plus the display fields a UI needs to render the
|
|
14
|
+
* "ending in 4242 expires 04/27" line.
|
|
15
|
+
*
|
|
16
|
+
* Safety floor: every `add()` input is screened for PAN-shaped
|
|
17
|
+
* strings (13-19 consecutive digits) and CVV-shaped fields, so an
|
|
18
|
+
* operator who accidentally wires the raw card form into this
|
|
19
|
+
* primitive gets a TypeError instead of a silent leak into the
|
|
20
|
+
* shop's database.
|
|
21
|
+
*
|
|
22
|
+
* Default-uniqueness is enforced two ways:
|
|
23
|
+
* - write-side, in the primitive: `setDefault` clears the
|
|
24
|
+
* previous default in the same call before flipping the new
|
|
25
|
+
* row's `is_default = 1`.
|
|
26
|
+
* - schema-side: a partial UNIQUE index over
|
|
27
|
+
* `(customer_id) WHERE is_default = 1 AND archived_at IS NULL`
|
|
28
|
+
* refuses a second simultaneous default at the SQL tier.
|
|
29
|
+
*
|
|
30
|
+
* Archive is one-way. The audit ledger records every state change
|
|
31
|
+
* (added / default_set / default_cleared / archived) so a GDPR
|
|
32
|
+
* data-subject access request can reconstruct the full lifecycle
|
|
33
|
+
* of the row.
|
|
34
|
+
*
|
|
35
|
+
* Composition:
|
|
36
|
+
* var pm = bShop.paymentMethods.create({ query: q });
|
|
37
|
+
* var saved = await pm.add({
|
|
38
|
+
* customer_id: cust.id,
|
|
39
|
+
* processor: "stripe",
|
|
40
|
+
* processor_token: "pm_1ABC…",
|
|
41
|
+
* brand: "visa",
|
|
42
|
+
* last4: "4242",
|
|
43
|
+
* exp_month: 4,
|
|
44
|
+
* exp_year: 2027,
|
|
45
|
+
* });
|
|
46
|
+
* await pm.setDefault(saved.id);
|
|
47
|
+
* var def = await pm.defaultForCustomer(cust.id);
|
|
48
|
+
* await pm.archive({ payment_method_id: saved.id, reason: "customer_request" });
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
var bShop;
|
|
52
|
+
function _b() {
|
|
53
|
+
if (!bShop) bShop = require("./index");
|
|
54
|
+
return bShop.framework;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var PROCESSORS = ["stripe", "paypal", "square", "braintree", "authorize_net"];
|
|
58
|
+
var ARCHIVE_REASONS = ["customer_request", "expired", "replaced", "fraud", "operator"];
|
|
59
|
+
var AUDIT_EVENTS = ["added", "default_set", "default_cleared", "archived"];
|
|
60
|
+
|
|
61
|
+
var MAX_TOKEN_LEN = 512;
|
|
62
|
+
var MAX_BRAND_LEN = 32;
|
|
63
|
+
var MAX_LABEL_LEN = 64;
|
|
64
|
+
var MAX_ACTOR_LEN = 128;
|
|
65
|
+
|
|
66
|
+
// Brand is a free-form short string. We refuse control bytes / CR /
|
|
67
|
+
// LF but otherwise let operators pass "visa" / "mc" / "paypal" /
|
|
68
|
+
// whatever their UI surfaces; this is a display field, not a
|
|
69
|
+
// branch-on-this dispatch key.
|
|
70
|
+
var BRAND_RE = /^[A-Za-z0-9 _.\-]{1,32}$/;
|
|
71
|
+
var LAST4_RE = /^[0-9]{4}$/;
|
|
72
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
73
|
+
|
|
74
|
+
// PAN screen: any field whose value is a string carrying 13-19
|
|
75
|
+
// consecutive ASCII digits (with or without space/dash separators
|
|
76
|
+
// that don't break the run) is refused outright. The regex matches
|
|
77
|
+
// the un-separated run — operators who pre-process a PAN with
|
|
78
|
+
// hyphens still trip the screen because we also collapse common
|
|
79
|
+
// separators before re-scanning.
|
|
80
|
+
var PAN_RUN_RE = /\d{13,19}/;
|
|
81
|
+
|
|
82
|
+
// UUID-shape fields are already validated as opaque identifiers by
|
|
83
|
+
// the `_uuid` guard. They share the digit/hyphen vocabulary the PAN
|
|
84
|
+
// regex screens against, so we skip the screen on a value that
|
|
85
|
+
// matches the canonical UUID shape — the dedicated UUID validator
|
|
86
|
+
// is the authoritative gate for those fields.
|
|
87
|
+
var UUID_SHAPE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
88
|
+
|
|
89
|
+
// CVV-shaped field name screen. We refuse any input key that smells
|
|
90
|
+
// like a CVV/CVC/CV2/CID even if its value is empty. Defensive
|
|
91
|
+
// against an operator passing `Object.assign({}, formData)` where
|
|
92
|
+
// the form had a `cvv` input.
|
|
93
|
+
var CVV_KEY_RE = /^(?:cvv|cvc|cv2|cid|card_security_code|security_code)$/i;
|
|
94
|
+
|
|
95
|
+
// ---- validators ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function _uuid(s, label) {
|
|
98
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
99
|
+
catch (e) {
|
|
100
|
+
throw new TypeError("paymentMethods: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _processor(p) {
|
|
105
|
+
if (typeof p !== "string" || PROCESSORS.indexOf(p) === -1) {
|
|
106
|
+
throw new TypeError("paymentMethods: processor must be one of " + PROCESSORS.join(", "));
|
|
107
|
+
}
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function _archiveReason(r) {
|
|
112
|
+
if (typeof r !== "string" || ARCHIVE_REASONS.indexOf(r) === -1) {
|
|
113
|
+
throw new TypeError("paymentMethods: reason must be one of " + ARCHIVE_REASONS.join(", "));
|
|
114
|
+
}
|
|
115
|
+
return r;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _last4(s) {
|
|
119
|
+
if (typeof s !== "string" || !LAST4_RE.test(s)) {
|
|
120
|
+
throw new TypeError("paymentMethods: last4 must be exactly 4 ASCII digits");
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _brand(s) {
|
|
126
|
+
if (typeof s !== "string" || !s.length) {
|
|
127
|
+
throw new TypeError("paymentMethods: brand must be a non-empty string");
|
|
128
|
+
}
|
|
129
|
+
if (s.length > MAX_BRAND_LEN) {
|
|
130
|
+
throw new TypeError("paymentMethods: brand must be <= " + MAX_BRAND_LEN + " characters");
|
|
131
|
+
}
|
|
132
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
133
|
+
throw new TypeError("paymentMethods: brand contains control bytes");
|
|
134
|
+
}
|
|
135
|
+
if (!BRAND_RE.test(s)) {
|
|
136
|
+
throw new TypeError("paymentMethods: brand contains characters outside [A-Za-z0-9 _.-]");
|
|
137
|
+
}
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _label(s) {
|
|
142
|
+
if (s == null) return null;
|
|
143
|
+
if (typeof s !== "string" || !s.length) {
|
|
144
|
+
throw new TypeError("paymentMethods: label must be a non-empty string when provided");
|
|
145
|
+
}
|
|
146
|
+
if (s.length > MAX_LABEL_LEN) {
|
|
147
|
+
throw new TypeError("paymentMethods: label must be <= " + MAX_LABEL_LEN + " characters");
|
|
148
|
+
}
|
|
149
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
150
|
+
throw new TypeError("paymentMethods: label contains control bytes");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _token(s) {
|
|
156
|
+
if (typeof s !== "string" || !s.length) {
|
|
157
|
+
throw new TypeError("paymentMethods: processor_token must be a non-empty string");
|
|
158
|
+
}
|
|
159
|
+
if (s.length > MAX_TOKEN_LEN) {
|
|
160
|
+
throw new TypeError("paymentMethods: processor_token must be <= " + MAX_TOKEN_LEN + " characters");
|
|
161
|
+
}
|
|
162
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
163
|
+
throw new TypeError("paymentMethods: processor_token contains control bytes");
|
|
164
|
+
}
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _expMonth(n) {
|
|
169
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 12) {
|
|
170
|
+
throw new TypeError("paymentMethods: exp_month must be an integer in 1..12");
|
|
171
|
+
}
|
|
172
|
+
return n;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _expYear(n) {
|
|
176
|
+
if (typeof n !== "number" || !Number.isInteger(n)) {
|
|
177
|
+
throw new TypeError("paymentMethods: exp_year must be an integer");
|
|
178
|
+
}
|
|
179
|
+
var nowYear = new Date().getUTCFullYear();
|
|
180
|
+
if (n < nowYear) {
|
|
181
|
+
throw new TypeError("paymentMethods: exp_year must be >= current year (" + nowYear + ")");
|
|
182
|
+
}
|
|
183
|
+
return n;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _actor(s) {
|
|
187
|
+
if (s == null) return null;
|
|
188
|
+
if (typeof s !== "string" || !s.length) {
|
|
189
|
+
throw new TypeError("paymentMethods: actor must be a non-empty string when provided");
|
|
190
|
+
}
|
|
191
|
+
if (s.length > MAX_ACTOR_LEN) {
|
|
192
|
+
throw new TypeError("paymentMethods: actor must be <= " + MAX_ACTOR_LEN + " characters");
|
|
193
|
+
}
|
|
194
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
195
|
+
throw new TypeError("paymentMethods: actor contains control bytes");
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Walk every (key, value) on the operator-supplied input and refuse
|
|
201
|
+
// anything that smells like a raw PAN or a CVV. The walk is
|
|
202
|
+
// shallow — only top-level string values on a plain object are
|
|
203
|
+
// inspected, which is the entire shape `add()` accepts. We collapse
|
|
204
|
+
// runs of spaces / hyphens before re-checking so a "4242-4242-4242-
|
|
205
|
+
// 4242" entry still trips the screen.
|
|
206
|
+
function _screenForRawCard(input) {
|
|
207
|
+
if (!input || typeof input !== "object") return;
|
|
208
|
+
var keys = Object.keys(input);
|
|
209
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
210
|
+
var k = keys[i];
|
|
211
|
+
if (CVV_KEY_RE.test(k)) {
|
|
212
|
+
throw new TypeError("paymentMethods: refused CVV-shaped field '" + k + "' — raw card data must never reach this primitive");
|
|
213
|
+
}
|
|
214
|
+
var v = input[k];
|
|
215
|
+
if (typeof v !== "string") continue;
|
|
216
|
+
// UUID-shape values are screened by `_uuid` further down; skip
|
|
217
|
+
// the PAN regex on them so a UUIDv7 whose hex happens to hold
|
|
218
|
+
// a long numeric run doesn't trip the operator-error refusal.
|
|
219
|
+
if (UUID_SHAPE_RE.test(v)) continue;
|
|
220
|
+
if (PAN_RUN_RE.test(v)) {
|
|
221
|
+
throw new TypeError("paymentMethods: refused PAN-shaped digit run in field '" + k + "' — raw card data must never reach this primitive");
|
|
222
|
+
}
|
|
223
|
+
var collapsed = v.replace(/[\s\-]/g, "");
|
|
224
|
+
if (collapsed !== v && PAN_RUN_RE.test(collapsed)) {
|
|
225
|
+
throw new TypeError("paymentMethods: refused PAN-shaped digit run in field '" + k + "' — raw card data must never reach this primitive");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _now() { return Date.now(); }
|
|
231
|
+
|
|
232
|
+
// ---- factory ------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
function create(opts) {
|
|
235
|
+
opts = opts || {};
|
|
236
|
+
var query = opts.query;
|
|
237
|
+
if (!query) {
|
|
238
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function _audit(paymentMethodId, event, ts, actor, reason) {
|
|
242
|
+
var auditId = _b().uuid.v7();
|
|
243
|
+
await query(
|
|
244
|
+
"INSERT INTO payment_method_audit (id, payment_method_id, event, occurred_at, actor, reason) " +
|
|
245
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
246
|
+
[auditId, paymentMethodId, event, ts, actor || null, reason || null],
|
|
247
|
+
);
|
|
248
|
+
return auditId;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
PROCESSORS: PROCESSORS,
|
|
253
|
+
ARCHIVE_REASONS: ARCHIVE_REASONS,
|
|
254
|
+
AUDIT_EVENTS: AUDIT_EVENTS,
|
|
255
|
+
|
|
256
|
+
add: async function (input) {
|
|
257
|
+
if (!input || typeof input !== "object") {
|
|
258
|
+
throw new TypeError("paymentMethods.add: input object required");
|
|
259
|
+
}
|
|
260
|
+
// PAN / CVV screen runs FIRST so a misuse is refused before
|
|
261
|
+
// we touch any of the validated fields.
|
|
262
|
+
_screenForRawCard(input);
|
|
263
|
+
|
|
264
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
265
|
+
var processor = _processor(input.processor);
|
|
266
|
+
var token = _token(input.processor_token);
|
|
267
|
+
var brand = _brand(input.brand);
|
|
268
|
+
var last4 = _last4(input.last4);
|
|
269
|
+
var expMonth = _expMonth(input.exp_month);
|
|
270
|
+
var expYear = _expYear(input.exp_year);
|
|
271
|
+
var label = _label(input.label);
|
|
272
|
+
var actor = _actor(input.actor);
|
|
273
|
+
|
|
274
|
+
var billingAddressId = null;
|
|
275
|
+
if (input.billing_address_id != null) {
|
|
276
|
+
billingAddressId = _uuid(input.billing_address_id, "billing_address_id");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
var id = _b().uuid.v7();
|
|
280
|
+
var ts = _now();
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await query(
|
|
284
|
+
"INSERT INTO payment_methods (id, customer_id, processor, processor_token, brand, last4, " +
|
|
285
|
+
"exp_month, exp_year, billing_address_id, label, is_default, archived_at, archive_reason, " +
|
|
286
|
+
"created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, NULL, NULL, ?11, ?11)",
|
|
287
|
+
[id, customerId, processor, token, brand, last4, expMonth, expYear, billingAddressId, label, ts],
|
|
288
|
+
);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// Unique-violation on (processor, processor_token) — the
|
|
291
|
+
// same processor token cannot be saved twice. Surface as a
|
|
292
|
+
// typed error so the caller can present an idempotent
|
|
293
|
+
// "already on file" message instead of a generic 500.
|
|
294
|
+
var msg = (e && e.message) || "";
|
|
295
|
+
if (/UNIQUE|unique/.test(msg) && /processor_token|processor,/.test(msg + "")) {
|
|
296
|
+
var dup = new Error("paymentMethods.add: processor_token already saved for this processor");
|
|
297
|
+
dup.code = "PAYMENT_METHOD_DUPLICATE_TOKEN";
|
|
298
|
+
throw dup;
|
|
299
|
+
}
|
|
300
|
+
throw e;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await _audit(id, "added", ts, actor, null);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
id: id,
|
|
307
|
+
customer_id: customerId,
|
|
308
|
+
processor: processor,
|
|
309
|
+
brand: brand,
|
|
310
|
+
last4: last4,
|
|
311
|
+
exp_month: expMonth,
|
|
312
|
+
exp_year: expYear,
|
|
313
|
+
billing_address_id: billingAddressId,
|
|
314
|
+
label: label,
|
|
315
|
+
is_default: false,
|
|
316
|
+
archived_at: null,
|
|
317
|
+
archive_reason: null,
|
|
318
|
+
created_at: ts,
|
|
319
|
+
updated_at: ts,
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
get: async function (paymentMethodId) {
|
|
324
|
+
_uuid(paymentMethodId, "payment_method_id");
|
|
325
|
+
var r = await query(
|
|
326
|
+
"SELECT * FROM payment_methods WHERE id = ?1",
|
|
327
|
+
[paymentMethodId],
|
|
328
|
+
);
|
|
329
|
+
if (!r.rows.length) return null;
|
|
330
|
+
return r.rows[0];
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
listForCustomer: async function (customerId, opts2) {
|
|
334
|
+
_uuid(customerId, "customer_id");
|
|
335
|
+
opts2 = opts2 || {};
|
|
336
|
+
var sql;
|
|
337
|
+
if (opts2.include_archived) {
|
|
338
|
+
sql = "SELECT * FROM payment_methods WHERE customer_id = ?1 " +
|
|
339
|
+
"ORDER BY is_default DESC, created_at DESC";
|
|
340
|
+
} else {
|
|
341
|
+
sql = "SELECT * FROM payment_methods WHERE customer_id = ?1 AND archived_at IS NULL " +
|
|
342
|
+
"ORDER BY is_default DESC, created_at DESC";
|
|
343
|
+
}
|
|
344
|
+
var r = await query(sql, [customerId]);
|
|
345
|
+
return r.rows;
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
setDefault: async function (paymentMethodId, opts3) {
|
|
349
|
+
_uuid(paymentMethodId, "payment_method_id");
|
|
350
|
+
opts3 = opts3 || {};
|
|
351
|
+
var actor = _actor(opts3.actor);
|
|
352
|
+
|
|
353
|
+
var r = await query(
|
|
354
|
+
"SELECT id, customer_id, archived_at, is_default FROM payment_methods WHERE id = ?1",
|
|
355
|
+
[paymentMethodId],
|
|
356
|
+
);
|
|
357
|
+
if (!r.rows.length) {
|
|
358
|
+
var miss = new Error("paymentMethods.setDefault: payment method not found");
|
|
359
|
+
miss.code = "PAYMENT_METHOD_NOT_FOUND";
|
|
360
|
+
throw miss;
|
|
361
|
+
}
|
|
362
|
+
var row = r.rows[0];
|
|
363
|
+
if (row.archived_at != null) {
|
|
364
|
+
var arch = new Error("paymentMethods.setDefault: payment method is archived");
|
|
365
|
+
arch.code = "PAYMENT_METHOD_ARCHIVED";
|
|
366
|
+
throw arch;
|
|
367
|
+
}
|
|
368
|
+
if (row.is_default === 1) {
|
|
369
|
+
// Already default — no-op, no audit churn.
|
|
370
|
+
return { id: row.id, customer_id: row.customer_id, is_default: true, changed: false };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
var ts = _now();
|
|
374
|
+
|
|
375
|
+
// Two-step write-side default movement. The partial UNIQUE
|
|
376
|
+
// index forbids two live defaults so we must clear the
|
|
377
|
+
// sibling FIRST, then set the new one. We capture the sibling
|
|
378
|
+
// id (if any) for the audit ledger before clearing it.
|
|
379
|
+
var sib = await query(
|
|
380
|
+
"SELECT id FROM payment_methods WHERE customer_id = ?1 AND is_default = 1 AND archived_at IS NULL AND id <> ?2",
|
|
381
|
+
[row.customer_id, paymentMethodId],
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
if (sib.rows.length) {
|
|
385
|
+
await query(
|
|
386
|
+
"UPDATE payment_methods SET is_default = 0, updated_at = ?1 " +
|
|
387
|
+
"WHERE customer_id = ?2 AND is_default = 1 AND archived_at IS NULL AND id <> ?3",
|
|
388
|
+
[ts, row.customer_id, paymentMethodId],
|
|
389
|
+
);
|
|
390
|
+
for (var i = 0; i < sib.rows.length; i += 1) {
|
|
391
|
+
await _audit(sib.rows[i].id, "default_cleared", ts, actor, null);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await query(
|
|
396
|
+
"UPDATE payment_methods SET is_default = 1, updated_at = ?1 WHERE id = ?2",
|
|
397
|
+
[ts, paymentMethodId],
|
|
398
|
+
);
|
|
399
|
+
await _audit(paymentMethodId, "default_set", ts, actor, null);
|
|
400
|
+
|
|
401
|
+
return { id: paymentMethodId, customer_id: row.customer_id, is_default: true, changed: true };
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
archive: async function (input) {
|
|
405
|
+
if (!input || typeof input !== "object") {
|
|
406
|
+
throw new TypeError("paymentMethods.archive: input object required");
|
|
407
|
+
}
|
|
408
|
+
_uuid(input.payment_method_id, "payment_method_id");
|
|
409
|
+
var reason = _archiveReason(input.reason);
|
|
410
|
+
var actor = _actor(input.actor);
|
|
411
|
+
|
|
412
|
+
var r = await query(
|
|
413
|
+
"SELECT id, archived_at, is_default FROM payment_methods WHERE id = ?1",
|
|
414
|
+
[input.payment_method_id],
|
|
415
|
+
);
|
|
416
|
+
if (!r.rows.length) {
|
|
417
|
+
var miss = new Error("paymentMethods.archive: payment method not found");
|
|
418
|
+
miss.code = "PAYMENT_METHOD_NOT_FOUND";
|
|
419
|
+
throw miss;
|
|
420
|
+
}
|
|
421
|
+
var row = r.rows[0];
|
|
422
|
+
if (row.archived_at != null) {
|
|
423
|
+
// Idempotent — already archived; return the row as-is.
|
|
424
|
+
var existing = await query(
|
|
425
|
+
"SELECT * FROM payment_methods WHERE id = ?1",
|
|
426
|
+
[input.payment_method_id],
|
|
427
|
+
);
|
|
428
|
+
return existing.rows[0] || null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
var ts = _now();
|
|
432
|
+
// Drop the default flag in the same UPDATE so the partial
|
|
433
|
+
// UNIQUE index never sees a "default + archived" row state
|
|
434
|
+
// that would block a future setDefault on a sibling.
|
|
435
|
+
await query(
|
|
436
|
+
"UPDATE payment_methods SET archived_at = ?1, archive_reason = ?2, is_default = 0, updated_at = ?1 " +
|
|
437
|
+
"WHERE id = ?3",
|
|
438
|
+
[ts, reason, input.payment_method_id],
|
|
439
|
+
);
|
|
440
|
+
await _audit(input.payment_method_id, "archived", ts, actor, reason);
|
|
441
|
+
|
|
442
|
+
var after = await query(
|
|
443
|
+
"SELECT * FROM payment_methods WHERE id = ?1",
|
|
444
|
+
[input.payment_method_id],
|
|
445
|
+
);
|
|
446
|
+
return after.rows[0] || null;
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
markExpired: async function (opts4) {
|
|
450
|
+
opts4 = opts4 || {};
|
|
451
|
+
var actor = _actor(opts4.actor);
|
|
452
|
+
|
|
453
|
+
var now = new Date();
|
|
454
|
+
var nowYear = now.getUTCFullYear();
|
|
455
|
+
var nowMonth = now.getUTCMonth() + 1;
|
|
456
|
+
var ts = _now();
|
|
457
|
+
|
|
458
|
+
// SELECT first so we can write per-row audit entries —
|
|
459
|
+
// markExpired is scheduler-callable and runs at low rate, so
|
|
460
|
+
// the SELECT-then-UPDATE pair is acceptable; archive flow
|
|
461
|
+
// through the same primitive surface guarantees a partial
|
|
462
|
+
// crash leaves no orphan row state (a row archived without
|
|
463
|
+
// its audit entry is still safe — the next sweep skips it
|
|
464
|
+
// because archived_at IS NULL filter).
|
|
465
|
+
var stale = await query(
|
|
466
|
+
"SELECT id FROM payment_methods " +
|
|
467
|
+
"WHERE archived_at IS NULL AND (exp_year < ?1 OR (exp_year = ?1 AND exp_month < ?2))",
|
|
468
|
+
[nowYear, nowMonth],
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
var ids = stale.rows.map(function (r2) { return r2.id; });
|
|
472
|
+
for (var i = 0; i < ids.length; i += 1) {
|
|
473
|
+
await query(
|
|
474
|
+
"UPDATE payment_methods SET archived_at = ?1, archive_reason = 'expired', is_default = 0, updated_at = ?1 " +
|
|
475
|
+
"WHERE id = ?2 AND archived_at IS NULL",
|
|
476
|
+
[ts, ids[i]],
|
|
477
|
+
);
|
|
478
|
+
await _audit(ids[i], "archived", ts, actor, "expired");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { archived_count: ids.length, archived_ids: ids };
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
defaultForCustomer: async function (customerId) {
|
|
485
|
+
_uuid(customerId, "customer_id");
|
|
486
|
+
var r = await query(
|
|
487
|
+
"SELECT * FROM payment_methods WHERE customer_id = ?1 AND is_default = 1 AND archived_at IS NULL",
|
|
488
|
+
[customerId],
|
|
489
|
+
);
|
|
490
|
+
return r.rows[0] || null;
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
byProcessorToken: async function (input) {
|
|
494
|
+
if (!input || typeof input !== "object") {
|
|
495
|
+
throw new TypeError("paymentMethods.byProcessorToken: input object required");
|
|
496
|
+
}
|
|
497
|
+
var processor = _processor(input.processor);
|
|
498
|
+
var token = _token(input.processor_token);
|
|
499
|
+
var r = await query(
|
|
500
|
+
"SELECT * FROM payment_methods WHERE processor = ?1 AND processor_token = ?2",
|
|
501
|
+
[processor, token],
|
|
502
|
+
);
|
|
503
|
+
return r.rows[0] || null;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
audit: async function (paymentMethodId) {
|
|
507
|
+
_uuid(paymentMethodId, "payment_method_id");
|
|
508
|
+
var r = await query(
|
|
509
|
+
"SELECT * FROM payment_method_audit WHERE payment_method_id = ?1 ORDER BY occurred_at ASC, id ASC",
|
|
510
|
+
[paymentMethodId],
|
|
511
|
+
);
|
|
512
|
+
return r.rows;
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
module.exports = {
|
|
518
|
+
create: create,
|
|
519
|
+
PROCESSORS: PROCESSORS,
|
|
520
|
+
ARCHIVE_REASONS: ARCHIVE_REASONS,
|
|
521
|
+
AUDIT_EVENTS: AUDIT_EVENTS,
|
|
522
|
+
};
|