@blamejs/blamejs-shop 0.4.27 → 0.4.29
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 +4 -0
- package/README.md +2 -2
- package/SECURITY.md +22 -0
- package/lib/admin.js +197 -39
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +550 -135
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +69 -3
- package/lib/payment.js +113 -7
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +39 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +9 -2
- package/package.json +1 -1
package/lib/auto-discount.js
CHANGED
|
@@ -1159,16 +1159,23 @@ function create(opts) {
|
|
|
1159
1159
|
throw new TypeError("autoDiscount.recordApplication: rule_slug " + JSON.stringify(slug) + " not found");
|
|
1160
1160
|
}
|
|
1161
1161
|
|
|
1162
|
+
// Idempotent per (rule, order): the unique index (migration 0231)
|
|
1163
|
+
// makes a re-delivered record a no-op, and the redemption counter
|
|
1164
|
+
// only advances when this call actually created the row — a retry
|
|
1165
|
+
// can never double-count a cap.
|
|
1162
1166
|
var id = b.uuid.v7();
|
|
1163
|
-
await query(
|
|
1167
|
+
var ins = await query(
|
|
1164
1168
|
"INSERT INTO auto_discount_applications (id, rule_slug, order_id, customer_id, savings_minor, applied_at) " +
|
|
1165
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
|
|
1169
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
|
|
1170
|
+
"ON CONFLICT(rule_slug, order_id) DO NOTHING",
|
|
1166
1171
|
[id, slug, orderId, customerId, savings, appliedAt],
|
|
1167
1172
|
);
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1173
|
+
if (Number(ins.rowCount || 0) === 1) {
|
|
1174
|
+
await query(
|
|
1175
|
+
"UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
|
|
1176
|
+
[_now(), slug],
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1172
1179
|
return {
|
|
1173
1180
|
id: id,
|
|
1174
1181
|
rule_slug: slug,
|
|
@@ -1179,6 +1186,167 @@ function create(opts) {
|
|
|
1179
1186
|
};
|
|
1180
1187
|
}
|
|
1181
1188
|
|
|
1189
|
+
// ---- pre-charge redemption claims -----------------------------------
|
|
1190
|
+
//
|
|
1191
|
+
// A capped rule is a finite resource, so checkout RESERVES it before any
|
|
1192
|
+
// money moves — the same reserve-then-settle discipline inventory holds
|
|
1193
|
+
// follow — instead of best-effort recording after the fact (which let a
|
|
1194
|
+
// single-use code apply to every concurrent order: the cap was only ever
|
|
1195
|
+
// read at quote time and incremented unconditionally post-commit).
|
|
1196
|
+
//
|
|
1197
|
+
// claimRedemption — atomically reserve one redemption under BOTH caps,
|
|
1198
|
+
// writing the application row up front keyed by a
|
|
1199
|
+
// caller-supplied claim ref (the order doesn't exist
|
|
1200
|
+
// yet). Fails CLOSED: { claimed: false, reason }.
|
|
1201
|
+
// linkClaimToOrder — re-key the claim row to the real order id once the
|
|
1202
|
+
// order exists.
|
|
1203
|
+
// releaseClaim — compensation for a checkout that died before its
|
|
1204
|
+
// order existed: delete the claim row and return the
|
|
1205
|
+
// reservation to the counter (floored at 0).
|
|
1206
|
+
//
|
|
1207
|
+
// The per-customer cap is enforced INSIDE the claim INSERT (a correlated
|
|
1208
|
+
// COUNT in the WHERE), and the total cap inside a guarded UPDATE — both
|
|
1209
|
+
// single statements, so concurrent claims serialize at the database and
|
|
1210
|
+
// the loser is refused rather than both passing a stale read. A re-claim
|
|
1211
|
+
// with the SAME (rule, claim_ref) — a checkout retry whose earlier
|
|
1212
|
+
// rollback didn't complete — reuses the existing claim instead of
|
|
1213
|
+
// double-reserving.
|
|
1214
|
+
|
|
1215
|
+
async function claimRedemption(input) {
|
|
1216
|
+
if (!input || typeof input !== "object") {
|
|
1217
|
+
throw new TypeError("autoDiscount.claimRedemption: input object required");
|
|
1218
|
+
}
|
|
1219
|
+
var slug = _slug(input.rule_slug);
|
|
1220
|
+
var claimRef = input.claim_ref;
|
|
1221
|
+
if (typeof claimRef !== "string" || !claimRef.length || claimRef.length > 128) {
|
|
1222
|
+
throw new TypeError("autoDiscount.claimRedemption: claim_ref must be a non-empty string (<= 128 chars)");
|
|
1223
|
+
}
|
|
1224
|
+
var savings = input.savings_minor == null ? 0 : input.savings_minor;
|
|
1225
|
+
_nonNegInt(savings, "savings_minor");
|
|
1226
|
+
var customerId = input.customer_id == null ? null : input.customer_id;
|
|
1227
|
+
if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
|
|
1228
|
+
throw new TypeError("autoDiscount.claimRedemption: customer_id must be a non-empty string when provided");
|
|
1229
|
+
}
|
|
1230
|
+
var rule = await getRule(slug);
|
|
1231
|
+
if (!rule) {
|
|
1232
|
+
throw new TypeError("autoDiscount.claimRedemption: rule_slug " + JSON.stringify(slug) + " not found");
|
|
1233
|
+
}
|
|
1234
|
+
var ts = _now();
|
|
1235
|
+
|
|
1236
|
+
// A claim this checkout already holds (a retry whose earlier rollback
|
|
1237
|
+
// didn't complete) is reused, not re-reserved — its counter increment
|
|
1238
|
+
// was already paid and its row already passed the caps.
|
|
1239
|
+
var existing = (await query(
|
|
1240
|
+
"SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
|
|
1241
|
+
[slug, claimRef],
|
|
1242
|
+
)).rows[0];
|
|
1243
|
+
if (existing) {
|
|
1244
|
+
return { claimed: true, id: existing.id, reused: true };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Reserve against the TOTAL cap first — one guarded atomic increment;
|
|
1248
|
+
// a refused update means the cap is exhausted (or was won by a
|
|
1249
|
+
// concurrent claim, which is the same answer).
|
|
1250
|
+
var counter = await query(
|
|
1251
|
+
"UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 " +
|
|
1252
|
+
"WHERE slug = ?2 AND (max_redemptions_total IS NULL OR redemptions_used < max_redemptions_total)",
|
|
1253
|
+
[ts, slug],
|
|
1254
|
+
);
|
|
1255
|
+
if (Number(counter.rowCount || 0) === 0) {
|
|
1256
|
+
return { claimed: false, reason: "total-cap" };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Write the claim row gated on the PER-CUSTOMER cap — the COUNT runs
|
|
1260
|
+
// inside the INSERT's WHERE, so two concurrent claims for the same
|
|
1261
|
+
// customer serialize at the database and the loser is refused. A guest
|
|
1262
|
+
// claim (no customer id) has no per-customer bound to enforce.
|
|
1263
|
+
var id = b.uuid.v7();
|
|
1264
|
+
var ins;
|
|
1265
|
+
try {
|
|
1266
|
+
ins = await query(
|
|
1267
|
+
"INSERT INTO auto_discount_applications (id, rule_slug, order_id, customer_id, savings_minor, applied_at) " +
|
|
1268
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6 " +
|
|
1269
|
+
"WHERE ?4 IS NULL OR ?7 IS NULL OR " +
|
|
1270
|
+
"(SELECT COUNT(*) FROM auto_discount_applications WHERE rule_slug = ?2 AND customer_id = ?4) < ?7",
|
|
1271
|
+
[id, slug, claimRef, customerId, savings, ts, rule.max_redemptions_per_customer],
|
|
1272
|
+
);
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
// UNIQUE(rule_slug, order_id) collision: this checkout already holds
|
|
1275
|
+
// a claim from a prior attempt whose rollback didn't complete —
|
|
1276
|
+
// reuse it (its counter reservation was already paid), after
|
|
1277
|
+
// returning the increment this call just took.
|
|
1278
|
+
if (/UNIQUE constraint failed/i.test((e && e.message) || "")) {
|
|
1279
|
+
await query(
|
|
1280
|
+
"UPDATE auto_discount_rules SET redemptions_used = " +
|
|
1281
|
+
"CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
|
|
1282
|
+
[_now(), slug],
|
|
1283
|
+
);
|
|
1284
|
+
var held = (await query(
|
|
1285
|
+
"SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
|
|
1286
|
+
[slug, claimRef],
|
|
1287
|
+
)).rows[0];
|
|
1288
|
+
return { claimed: true, id: held ? held.id : null, reused: true };
|
|
1289
|
+
}
|
|
1290
|
+
throw e;
|
|
1291
|
+
}
|
|
1292
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
1293
|
+
// Per-customer cap refused — return the total-cap reservation.
|
|
1294
|
+
await query(
|
|
1295
|
+
"UPDATE auto_discount_rules SET redemptions_used = " +
|
|
1296
|
+
"CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
|
|
1297
|
+
[_now(), slug],
|
|
1298
|
+
);
|
|
1299
|
+
return { claimed: false, reason: "customer-cap" };
|
|
1300
|
+
}
|
|
1301
|
+
return { claimed: true, id: id };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function linkClaimToOrder(input) {
|
|
1305
|
+
if (!input || typeof input !== "object") {
|
|
1306
|
+
throw new TypeError("autoDiscount.linkClaimToOrder: input object required");
|
|
1307
|
+
}
|
|
1308
|
+
var slug = _slug(input.rule_slug);
|
|
1309
|
+
var claimRef = input.claim_ref;
|
|
1310
|
+
var orderId = input.order_id;
|
|
1311
|
+
if (typeof claimRef !== "string" || !claimRef.length) {
|
|
1312
|
+
throw new TypeError("autoDiscount.linkClaimToOrder: claim_ref must be a non-empty string");
|
|
1313
|
+
}
|
|
1314
|
+
if (typeof orderId !== "string" || !orderId.length || orderId.length > 128) {
|
|
1315
|
+
throw new TypeError("autoDiscount.linkClaimToOrder: order_id must be a non-empty string (<= 128 chars)");
|
|
1316
|
+
}
|
|
1317
|
+
var res = await query(
|
|
1318
|
+
"UPDATE auto_discount_applications SET order_id = ?1 WHERE rule_slug = ?2 AND order_id = ?3",
|
|
1319
|
+
[orderId, slug, claimRef],
|
|
1320
|
+
);
|
|
1321
|
+
return Number(res.rowCount || 0) === 1;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function releaseClaim(input) {
|
|
1325
|
+
if (!input || typeof input !== "object") {
|
|
1326
|
+
throw new TypeError("autoDiscount.releaseClaim: input object required");
|
|
1327
|
+
}
|
|
1328
|
+
var slug = _slug(input.rule_slug);
|
|
1329
|
+
var claimRef = input.claim_ref;
|
|
1330
|
+
if (typeof claimRef !== "string" || !claimRef.length) {
|
|
1331
|
+
throw new TypeError("autoDiscount.releaseClaim: claim_ref must be a non-empty string");
|
|
1332
|
+
}
|
|
1333
|
+
// Delete-then-decrement, each guarded: the delete's rowCount is the
|
|
1334
|
+
// claim's existence check (a double release finds no row and stops),
|
|
1335
|
+
// and the decrement floors at zero so a release can never drive the
|
|
1336
|
+
// counter negative.
|
|
1337
|
+
var del = await query(
|
|
1338
|
+
"DELETE FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
|
|
1339
|
+
[slug, claimRef],
|
|
1340
|
+
);
|
|
1341
|
+
if (Number(del.rowCount || 0) === 0) return false;
|
|
1342
|
+
await query(
|
|
1343
|
+
"UPDATE auto_discount_rules SET redemptions_used = " +
|
|
1344
|
+
"CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
|
|
1345
|
+
[_now(), slug],
|
|
1346
|
+
);
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1182
1350
|
// ---- metricsForRule ------------------------------------------------
|
|
1183
1351
|
|
|
1184
1352
|
async function metricsForRule(input) {
|
|
@@ -1222,6 +1390,9 @@ function create(opts) {
|
|
|
1222
1390
|
ruleForCode: ruleForCode,
|
|
1223
1391
|
evaluate: evaluate,
|
|
1224
1392
|
recordApplication: recordApplication,
|
|
1393
|
+
claimRedemption: claimRedemption,
|
|
1394
|
+
linkClaimToOrder: linkClaimToOrder,
|
|
1395
|
+
releaseClaim: releaseClaim,
|
|
1225
1396
|
metricsForRule: metricsForRule,
|
|
1226
1397
|
};
|
|
1227
1398
|
}
|