@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.
@@ -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
- await query(
1169
- "UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
1170
- [_now(), slug],
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
  }