@aglyn/plugins-commerce 1.0.0-beta.202 → 1.0.0-beta.203

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-commerce",
3
- "version": "1.0.0-beta.202",
3
+ "version": "1.0.0-beta.203",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.202",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.202",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.202",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.202",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.202",
33
- "@aglyn/shared-util-email": "1.0.0-beta.202",
34
- "@aglyn/shared-util-http": "1.0.0-beta.202",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.202",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.202",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.202",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.202",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.202",
28
+ "@aglyn/aglyn": "1.0.0-beta.203",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.203",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.203",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.203",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.203",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.203",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.203",
35
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.203",
36
+ "@aglyn/shared-util-tools": "1.0.0-beta.203",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.203",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.203",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.203",
40
40
  "@stripe/react-stripe-js": "^3.10.0",
41
41
  "@stripe/stripe-js": "^5.10.0",
42
42
  "@swc/helpers": "0.5.23",
@@ -61,9 +61,11 @@ export interface GiftCardHold {
61
61
  }
62
62
  /**
63
63
  * Why a card cannot be redeemed for now (AGL-3363): the payment that bought
64
- * it drew an issuer's early fraud warning, a Radar review, or a dispute.
64
+ * it drew an issuer's early fraud warning, a Radar review, or a dispute; or
65
+ * part of it was refunded outside the console and the refund could not be
66
+ * matched to the order's lines (`refund-review`).
65
67
  */
66
- export type GiftCardFreezeReason = 'early-fraud-warning' | 'radar-review' | 'dispute';
68
+ export type GiftCardFreezeReason = 'early-fraud-warning' | 'radar-review' | 'dispute' | 'refund-review';
67
69
  /** Why a card's balance was taken back rather than spent. */
68
70
  export type GiftCardVoidReason = 'refund' | 'dispute-lost';
69
71
  /** `hosts/{hostId}/giftCards/{code}` doc, as far as redemption cares. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-gift-cards.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Gift-card hold arithmetic (AGL-2449).\n *\n * A gift card is CASH, and until this module existed it was the one limited\n * resource in the order path with no reserve at all. `cart-checkout.ts` read\n * `balanceCents` with a plain `.get()`, minted a Stripe coupon for that much,\n * and the webhook decremented the card minutes later with a bare\n * `FieldValue.increment(-N)`. The increment is atomic, so nothing was ever\n * LOST — but nothing ever CHECKED either, so two checkouts entering the same\n * code both read $50, both got $50 off, and the card settled at `-$50`. The\n * merchant shipped $100 of goods against a $50 card, and the outstanding\n * liability total went negative — the exact corruption `redeemExistingOrRecord`\n * already reasons about for a DELETED card, arriving through a live one.\n *\n * The stock fix (AGL-2320) could not be copied here: stock is decremented after\n * the money has moved and therefore cannot refuse, so it locks and reports the\n * shortfall. A gift card is applied BEFORE Stripe is contacted, so this door can\n * still say no — and refusing is the only thing that actually keeps the money.\n *\n * So the balance is HELD at checkout and SETTLED at the webhook, both in one\n * transaction against one document. Holds live in a map on the card itself\n * keyed by Stripe session id, which is what keeps this to a single-document\n * transaction: no fan-out, no second collection, no index.\n *\n * ## Why holds expire rather than being swept\n *\n * An abandoned checkout must not stand a customer's money off indefinitely, and\n * a sweep job is the wrong instrument: `process-abandoned.ts` is the only\n * existing sweep and it returns 501 when SMTP is unconfigured, so a\n * release that depended on it would not run at all for a self-hosted store with\n * no mail transport. Instead every hold carries `expiresAtMs` and EVERY read\n * prunes, so the release is a consequence of the next read rather than of a job\n * that may never run. A card nobody touches again holds nothing that matters,\n * because nobody is reading it either.\n *\n * The TTL matches Stripe's Checkout Session lifetime rather than something\n * shorter. A hold that lapsed while its session was still payable would reopen\n * the very window this closes — the shopper pays a session whose hold has\n * evaporated and the balance is spent twice again.\n */\n\n/** One in-flight claim on a card's balance, keyed by Stripe session id. */\nexport interface GiftCardHold {\n cents: number\n expiresAtMs: number\n}\n\n/**\n * Why a card cannot be redeemed for now (AGL-3363): the payment that bought\n * it drew an issuer's early fraud warning, a Radar review, or a dispute.\n */\nexport type GiftCardFreezeReason = 'early-fraud-warning' | 'radar-review' | 'dispute'\n\n/** Why a card's balance was taken back rather than spent. */\nexport type GiftCardVoidReason = 'refund' | 'dispute-lost'\n\n/** `hosts/{hostId}/giftCards/{code}` doc, as far as redemption cares. */\nexport interface HostGiftCard {\n balanceCents?: number\n /** Session id → hold. Absent on every card issued before AGL-2449. */\n holds?: Record<string, GiftCardHold>\n voidedAtMs?: number\n voidedReason?: GiftCardVoidReason\n /** Set while the purchase is under a fraud signal; nothing can redeem it. */\n frozenAtMs?: number\n frozenReason?: GiftCardFreezeReason\n}\n\n/*==========================================\n * GIFT CARDS ARE A CASH-OUT (AGL-3363).\n *\n * Buying store credit with a stolen card and spending it before the\n * chargeback lands is the standard way to turn card data into goods. Three\n * rules, all shared by every door that sells or redeems a card:\n *\n * 1. A PURCHASE HAS A CEILING. One checkout may buy at most\n * {@link GIFT_CARD_PURCHASE_LIMITS} of gift cards — lower while the\n * workspace is young, when a stolen-card storefront is likeliest — and\n * one visitor may buy gift cards only so many times a day. A real shop's\n * holiday order ($100 cards for a team) sits well inside both.\n * 2. A SIGNAL FREEZES. An early fraud warning, a Radar review or a dispute\n * on the purchase freezes every card it issued (`frozenAtMs`): its balance\n * stays, and nothing can redeem it until the merchant releases it or the\n * dispute is won.\n * 3. A REVERSAL VOIDS. A refund of the purchase, or a lost dispute, zeroes\n * what is left on its cards — the same zeroing the console's Void button\n * does, never a delete (AGL-1767).\n *=========================================*/\n\nexport const GIFT_CARD_PURCHASE_LIMITS = {\n /** Gift-card value one checkout may buy. */\n perOrderCents: 500_000,\n /** …while the workspace is young (`isYoungWorkspace`). */\n perOrderCentsYoung: 50_000,\n /** Checkouts buying gift cards, per visitor address per day. */\n perVisitorDaily: 5,\n /** …while the workspace is young. */\n perVisitorDailyYoung: 2,\n} as const\n\n/** The refusal a checkout gives past the per-order ceiling, or null. */\nexport function giftCardPurchaseRefusal(input: {\n giftCardCents: number\n young: boolean\n}): string | null {\n const ceiling = input.young\n ? GIFT_CARD_PURCHASE_LIMITS.perOrderCentsYoung\n : GIFT_CARD_PURCHASE_LIMITS.perOrderCents\n if (!(input.giftCardCents > ceiling)) return null\n // The ceiling is not named: a shopper who is testing it must not be told\n // where it is. A real buyer of a large gift order is sent to the shop.\n return (\n 'This order has more in gift cards than can be bought in one online ' +\n 'checkout. Please contact the shop to complete it.'\n )\n}\n\n/** Is the card frozen by a fraud signal on the payment that bought it? */\nexport function isGiftCardFrozen(card: HostGiftCard | undefined): boolean {\n return Number(card?.frozenAtMs) > 0\n}\n\n/**\n * Stripe Checkout Sessions expire 24h after creation, so a hold outlives any\n * session that can still be paid. See the module docblock for why a shorter\n * TTL is not the safer choice it looks like.\n */\nexport const GIFT_CARD_HOLD_TTL_MS = 24 * 60 * 60 * 1000\n\n/** One money figure off an untrusted document, as whole non-negative cents. */\nfunction cents(value: unknown): number {\n const number = Math.round(Number(value))\n return Number.isFinite(number) && number > 0 ? number : 0\n}\n\n/**\n * The card's holds with the lapsed ones dropped.\n *\n * Total, not a filter over a trusted shape: a hold whose `expiresAtMs` is\n * absent or non-numeric is treated as EXPIRED rather than eternal, so a\n * malformed row releases the money instead of stranding it. The one direction\n * that cannot be wrong is the one that keeps a customer from spending their own\n * balance forever.\n */\nexport function pruneGiftCardHolds(\n holds: Record<string, GiftCardHold> | undefined,\n nowMs: number,\n): Record<string, GiftCardHold> {\n const live: Record<string, GiftCardHold> = {}\n for (const [sessionId, hold] of Object.entries(holds ?? {})) {\n const expiresAtMs = Number(hold?.expiresAtMs)\n if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) continue\n const amount = cents(hold?.cents)\n if (amount > 0) live[sessionId] = { cents: amount, expiresAtMs }\n }\n return live\n}\n\n/**\n * What a NEW checkout may apply: the balance less every hold still standing.\n *\n * Never negative. A card whose holds already exceed its balance (possible only\n * on a card voided mid-flight, where the console zeroes `balanceCents` while\n * holds stand) reads as empty rather than as a debt to be collected.\n */\nexport function giftCardAvailableCents(\n card: HostGiftCard | undefined,\n nowMs: number,\n): number {\n // A frozen card offers nothing to a NEW checkout (AGL-3363). Settlement of\n // a hold placed before the freeze is left alone: that shopper has paid.\n if (isGiftCardFrozen(card)) return 0\n const held = Object.values(pruneGiftCardHolds(card?.holds, nowMs)).reduce(\n (sum, hold) => sum + hold.cents,\n 0,\n )\n return Math.max(0, cents(card?.balanceCents) - held)\n}\n\n/**\n * What settling `sessionId` should actually take off the card.\n *\n * Capped at the live balance, not at the hold, and that is the point of the\n * function. The hold is what this session RESERVED; the balance is what the\n * card can still give up. They diverge when the card was voided or hand-adjusted\n * between the hold and the payment, and in that window paying out the hold would\n * drive the balance negative — reintroducing the corrupted liability aggregate\n * from the other side.\n *\n * A session with no hold settles ZERO. That is the redelivery case (the first\n * delivery consumed it) and the pre-AGL-2449 case (a session that predates\n * holds), and both must be no-ops rather than a second decrement.\n */\nexport function giftCardSettlementCents(\n card: HostGiftCard | undefined,\n sessionId: string,\n // Deliberately unused, and kept so this reads as the sibling of\n // `giftCardAvailableCents` at every call site. The asymmetry IS the rule\n // below: availability is a question about now, settlement is not.\n _nowMs: number,\n): number {\n // NOT pruned: a hold that lapsed while its session sat unpaid is still owed\n // once that session is paid. Expiry governs what a NEW checkout may claim,\n // never whether a completed payment is honoured — dropping it here would take\n // the discount from the shopper and give nothing back to the merchant.\n const hold = (card?.holds ?? {})[sessionId]\n return Math.min(cents(hold?.cents), cents(card?.balanceCents))\n}\n"],"names":["GIFT_CARD_PURCHASE_LIMITS","perOrderCents","perOrderCentsYoung","perVisitorDaily","perVisitorDailyYoung","giftCardPurchaseRefusal","input","ceiling","young","giftCardCents","isGiftCardFrozen","card","Number","frozenAtMs","GIFT_CARD_HOLD_TTL_MS","cents","value","number","Math","round","isFinite","pruneGiftCardHolds","holds","nowMs","live","sessionId","hold","Object","entries","expiresAtMs","amount","giftCardAvailableCents","held","values","reduce","sum","max","balanceCents","giftCardSettlementCents","_nowMs","min"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,yEAAyE,GA2BzE;;;;;;;;;;;;;;;;;;;2CAmB2C,GAE3C,OAAO,MAAMA,4BAA4B;IACvC,0CAA0C,GAC1CC,eAAe;IACf,wDAAwD,GACxDC,oBAAoB;IACpB,8DAA8D,GAC9DC,iBAAiB;IACjB,mCAAmC,GACnCC,sBAAsB;AACxB,EAAU;AAEV,sEAAsE,GACtE,OAAO,SAASC,wBAAwBC,KAGvC;IACC,MAAMC,UAAUD,MAAME,KAAK,GACvBR,0BAA0BE,kBAAkB,GAC5CF,0BAA0BC,aAAa;IAC3C,IAAI,CAAEK,CAAAA,MAAMG,aAAa,GAAGF,OAAM,GAAI,OAAO;IAC7C,yEAAyE;IACzE,uEAAuE;IACvE,OACE,wEACA;AAEJ;AAEA,wEAAwE,GACxE,OAAO,SAASG,iBAAiBC,IAA8B;IAC7D,OAAOC,OAAOD,wBAAAA,KAAME,UAAU,IAAI;AACpC;AAEA;;;;CAIC,GACD,OAAO,MAAMC,wBAAwB,KAAK,KAAK,KAAK,KAAI;AAExD,6EAA6E,GAC7E,SAASC,MAAMC,KAAc;IAC3B,MAAMC,SAASC,KAAKC,KAAK,CAACP,OAAOI;IACjC,OAAOJ,OAAOQ,QAAQ,CAACH,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,mBACdC,KAA+C,EAC/CC,KAAa;IAEb,MAAMC,OAAqC,CAAC;IAC5C,KAAK,MAAM,CAACC,WAAWC,KAAK,IAAIC,OAAOC,OAAO,CAACN,gBAAAA,QAAS,CAAC,GAAI;QAC3D,MAAMO,cAAcjB,OAAOc,wBAAAA,KAAMG,WAAW;QAC5C,IAAI,CAACjB,OAAOQ,QAAQ,CAACS,gBAAgBA,eAAeN,OAAO;QAC3D,MAAMO,SAASf,MAAMW,wBAAAA,KAAMX,KAAK;QAChC,IAAIe,SAAS,GAAGN,IAAI,CAACC,UAAU,GAAG;YAAEV,OAAOe;YAAQD;QAAY;IACjE;IACA,OAAOL;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,uBACdpB,IAA8B,EAC9BY,KAAa;IAEb,2EAA2E;IAC3E,wEAAwE;IACxE,IAAIb,iBAAiBC,OAAO,OAAO;IACnC,MAAMqB,OAAOL,OAAOM,MAAM,CAACZ,mBAAmBV,wBAAAA,KAAMW,KAAK,EAAEC,QAAQW,MAAM,CACvE,CAACC,KAAKT,OAASS,MAAMT,KAAKX,KAAK,EAC/B;IAEF,OAAOG,KAAKkB,GAAG,CAAC,GAAGrB,MAAMJ,wBAAAA,KAAM0B,YAAY,IAAIL;AACjD;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASM,wBACd3B,IAA8B,EAC9Bc,SAAiB,EACjB,gEAAgE;AAChE,yEAAyE;AACzE,kEAAkE;AAClEc,MAAc;;IAEd,4EAA4E;IAC5E,2EAA2E;IAC3E,8EAA8E;IAC9E,uEAAuE;IACvE,MAAMb,OAAO,SAACf,wBAAAA,KAAMW,KAAK,mBAAI,CAAC,EAAE,CAACG,UAAU;IAC3C,OAAOP,KAAKsB,GAAG,CAACzB,MAAMW,wBAAAA,KAAMX,KAAK,GAAGA,MAAMJ,wBAAAA,KAAM0B,YAAY;AAC9D"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-gift-cards.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Gift-card hold arithmetic (AGL-2449).\n *\n * A gift card is CASH, and until this module existed it was the one limited\n * resource in the order path with no reserve at all. `cart-checkout.ts` read\n * `balanceCents` with a plain `.get()`, minted a Stripe coupon for that much,\n * and the webhook decremented the card minutes later with a bare\n * `FieldValue.increment(-N)`. The increment is atomic, so nothing was ever\n * LOST — but nothing ever CHECKED either, so two checkouts entering the same\n * code both read $50, both got $50 off, and the card settled at `-$50`. The\n * merchant shipped $100 of goods against a $50 card, and the outstanding\n * liability total went negative — the exact corruption `redeemExistingOrRecord`\n * already reasons about for a DELETED card, arriving through a live one.\n *\n * The stock fix (AGL-2320) could not be copied here: stock is decremented after\n * the money has moved and therefore cannot refuse, so it locks and reports the\n * shortfall. A gift card is applied BEFORE Stripe is contacted, so this door can\n * still say no — and refusing is the only thing that actually keeps the money.\n *\n * So the balance is HELD at checkout and SETTLED at the webhook, both in one\n * transaction against one document. Holds live in a map on the card itself\n * keyed by Stripe session id, which is what keeps this to a single-document\n * transaction: no fan-out, no second collection, no index.\n *\n * ## Why holds expire rather than being swept\n *\n * An abandoned checkout must not stand a customer's money off indefinitely, and\n * a sweep job is the wrong instrument: `process-abandoned.ts` is the only\n * existing sweep and it returns 501 when SMTP is unconfigured, so a\n * release that depended on it would not run at all for a self-hosted store with\n * no mail transport. Instead every hold carries `expiresAtMs` and EVERY read\n * prunes, so the release is a consequence of the next read rather than of a job\n * that may never run. A card nobody touches again holds nothing that matters,\n * because nobody is reading it either.\n *\n * The TTL matches Stripe's Checkout Session lifetime rather than something\n * shorter. A hold that lapsed while its session was still payable would reopen\n * the very window this closes — the shopper pays a session whose hold has\n * evaporated and the balance is spent twice again.\n */\n\n/** One in-flight claim on a card's balance, keyed by Stripe session id. */\nexport interface GiftCardHold {\n cents: number\n expiresAtMs: number\n}\n\n/**\n * Why a card cannot be redeemed for now (AGL-3363): the payment that bought\n * it drew an issuer's early fraud warning, a Radar review, or a dispute; or\n * part of it was refunded outside the console and the refund could not be\n * matched to the order's lines (`refund-review`).\n */\nexport type GiftCardFreezeReason =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n | 'refund-review'\n\n/** Why a card's balance was taken back rather than spent. */\nexport type GiftCardVoidReason = 'refund' | 'dispute-lost'\n\n/** `hosts/{hostId}/giftCards/{code}` doc, as far as redemption cares. */\nexport interface HostGiftCard {\n balanceCents?: number\n /** Session id → hold. Absent on every card issued before AGL-2449. */\n holds?: Record<string, GiftCardHold>\n voidedAtMs?: number\n voidedReason?: GiftCardVoidReason\n /** Set while the purchase is under a fraud signal; nothing can redeem it. */\n frozenAtMs?: number\n frozenReason?: GiftCardFreezeReason\n}\n\n/*==========================================\n * GIFT CARDS ARE A CASH-OUT (AGL-3363).\n *\n * Buying store credit with a stolen card and spending it before the\n * chargeback lands is the standard way to turn card data into goods. Three\n * rules, all shared by every door that sells or redeems a card:\n *\n * 1. A PURCHASE HAS A CEILING. One checkout may buy at most\n * {@link GIFT_CARD_PURCHASE_LIMITS} of gift cards — lower while the\n * workspace is young, when a stolen-card storefront is likeliest — and\n * one visitor may buy gift cards only so many times a day. A real shop's\n * holiday order ($100 cards for a team) sits well inside both.\n * 2. A SIGNAL FREEZES. An early fraud warning, a Radar review or a dispute\n * on the purchase freezes every card it issued (`frozenAtMs`): its balance\n * stays, and nothing can redeem it until the merchant releases it or the\n * dispute is won.\n * 3. A REVERSAL VOIDS. A refund of the purchase, or a lost dispute, zeroes\n * what is left on its cards — the same zeroing the console's Void button\n * does, never a delete (AGL-1767).\n *=========================================*/\n\nexport const GIFT_CARD_PURCHASE_LIMITS = {\n /** Gift-card value one checkout may buy. */\n perOrderCents: 500_000,\n /** …while the workspace is young (`isYoungWorkspace`). */\n perOrderCentsYoung: 50_000,\n /** Checkouts buying gift cards, per visitor address per day. */\n perVisitorDaily: 5,\n /** …while the workspace is young. */\n perVisitorDailyYoung: 2,\n} as const\n\n/** The refusal a checkout gives past the per-order ceiling, or null. */\nexport function giftCardPurchaseRefusal(input: {\n giftCardCents: number\n young: boolean\n}): string | null {\n const ceiling = input.young\n ? GIFT_CARD_PURCHASE_LIMITS.perOrderCentsYoung\n : GIFT_CARD_PURCHASE_LIMITS.perOrderCents\n if (!(input.giftCardCents > ceiling)) return null\n // The ceiling is not named: a shopper who is testing it must not be told\n // where it is. A real buyer of a large gift order is sent to the shop.\n return (\n 'This order has more in gift cards than can be bought in one online ' +\n 'checkout. Please contact the shop to complete it.'\n )\n}\n\n/** Is the card frozen by a fraud signal on the payment that bought it? */\nexport function isGiftCardFrozen(card: HostGiftCard | undefined): boolean {\n return Number(card?.frozenAtMs) > 0\n}\n\n/**\n * Stripe Checkout Sessions expire 24h after creation, so a hold outlives any\n * session that can still be paid. See the module docblock for why a shorter\n * TTL is not the safer choice it looks like.\n */\nexport const GIFT_CARD_HOLD_TTL_MS = 24 * 60 * 60 * 1000\n\n/** One money figure off an untrusted document, as whole non-negative cents. */\nfunction cents(value: unknown): number {\n const number = Math.round(Number(value))\n return Number.isFinite(number) && number > 0 ? number : 0\n}\n\n/**\n * The card's holds with the lapsed ones dropped.\n *\n * Total, not a filter over a trusted shape: a hold whose `expiresAtMs` is\n * absent or non-numeric is treated as EXPIRED rather than eternal, so a\n * malformed row releases the money instead of stranding it. The one direction\n * that cannot be wrong is the one that keeps a customer from spending their own\n * balance forever.\n */\nexport function pruneGiftCardHolds(\n holds: Record<string, GiftCardHold> | undefined,\n nowMs: number,\n): Record<string, GiftCardHold> {\n const live: Record<string, GiftCardHold> = {}\n for (const [sessionId, hold] of Object.entries(holds ?? {})) {\n const expiresAtMs = Number(hold?.expiresAtMs)\n if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) continue\n const amount = cents(hold?.cents)\n if (amount > 0) live[sessionId] = { cents: amount, expiresAtMs }\n }\n return live\n}\n\n/**\n * What a NEW checkout may apply: the balance less every hold still standing.\n *\n * Never negative. A card whose holds already exceed its balance (possible only\n * on a card voided mid-flight, where the console zeroes `balanceCents` while\n * holds stand) reads as empty rather than as a debt to be collected.\n */\nexport function giftCardAvailableCents(\n card: HostGiftCard | undefined,\n nowMs: number,\n): number {\n // A frozen card offers nothing to a NEW checkout (AGL-3363). Settlement of\n // a hold placed before the freeze is left alone: that shopper has paid.\n if (isGiftCardFrozen(card)) return 0\n const held = Object.values(pruneGiftCardHolds(card?.holds, nowMs)).reduce(\n (sum, hold) => sum + hold.cents,\n 0,\n )\n return Math.max(0, cents(card?.balanceCents) - held)\n}\n\n/**\n * What settling `sessionId` should actually take off the card.\n *\n * Capped at the live balance, not at the hold, and that is the point of the\n * function. The hold is what this session RESERVED; the balance is what the\n * card can still give up. They diverge when the card was voided or hand-adjusted\n * between the hold and the payment, and in that window paying out the hold would\n * drive the balance negative — reintroducing the corrupted liability aggregate\n * from the other side.\n *\n * A session with no hold settles ZERO. That is the redelivery case (the first\n * delivery consumed it) and the pre-AGL-2449 case (a session that predates\n * holds), and both must be no-ops rather than a second decrement.\n */\nexport function giftCardSettlementCents(\n card: HostGiftCard | undefined,\n sessionId: string,\n // Deliberately unused, and kept so this reads as the sibling of\n // `giftCardAvailableCents` at every call site. The asymmetry IS the rule\n // below: availability is a question about now, settlement is not.\n _nowMs: number,\n): number {\n // NOT pruned: a hold that lapsed while its session sat unpaid is still owed\n // once that session is paid. Expiry governs what a NEW checkout may claim,\n // never whether a completed payment is honoured — dropping it here would take\n // the discount from the shopper and give nothing back to the merchant.\n const hold = (card?.holds ?? {})[sessionId]\n return Math.min(cents(hold?.cents), cents(card?.balanceCents))\n}\n"],"names":["GIFT_CARD_PURCHASE_LIMITS","perOrderCents","perOrderCentsYoung","perVisitorDaily","perVisitorDailyYoung","giftCardPurchaseRefusal","input","ceiling","young","giftCardCents","isGiftCardFrozen","card","Number","frozenAtMs","GIFT_CARD_HOLD_TTL_MS","cents","value","number","Math","round","isFinite","pruneGiftCardHolds","holds","nowMs","live","sessionId","hold","Object","entries","expiresAtMs","amount","giftCardAvailableCents","held","values","reduce","sum","max","balanceCents","giftCardSettlementCents","_nowMs","min"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,yEAAyE,GAiCzE;;;;;;;;;;;;;;;;;;;2CAmB2C,GAE3C,OAAO,MAAMA,4BAA4B;IACvC,0CAA0C,GAC1CC,eAAe;IACf,wDAAwD,GACxDC,oBAAoB;IACpB,8DAA8D,GAC9DC,iBAAiB;IACjB,mCAAmC,GACnCC,sBAAsB;AACxB,EAAU;AAEV,sEAAsE,GACtE,OAAO,SAASC,wBAAwBC,KAGvC;IACC,MAAMC,UAAUD,MAAME,KAAK,GACvBR,0BAA0BE,kBAAkB,GAC5CF,0BAA0BC,aAAa;IAC3C,IAAI,CAAEK,CAAAA,MAAMG,aAAa,GAAGF,OAAM,GAAI,OAAO;IAC7C,yEAAyE;IACzE,uEAAuE;IACvE,OACE,wEACA;AAEJ;AAEA,wEAAwE,GACxE,OAAO,SAASG,iBAAiBC,IAA8B;IAC7D,OAAOC,OAAOD,wBAAAA,KAAME,UAAU,IAAI;AACpC;AAEA;;;;CAIC,GACD,OAAO,MAAMC,wBAAwB,KAAK,KAAK,KAAK,KAAI;AAExD,6EAA6E,GAC7E,SAASC,MAAMC,KAAc;IAC3B,MAAMC,SAASC,KAAKC,KAAK,CAACP,OAAOI;IACjC,OAAOJ,OAAOQ,QAAQ,CAACH,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,mBACdC,KAA+C,EAC/CC,KAAa;IAEb,MAAMC,OAAqC,CAAC;IAC5C,KAAK,MAAM,CAACC,WAAWC,KAAK,IAAIC,OAAOC,OAAO,CAACN,gBAAAA,QAAS,CAAC,GAAI;QAC3D,MAAMO,cAAcjB,OAAOc,wBAAAA,KAAMG,WAAW;QAC5C,IAAI,CAACjB,OAAOQ,QAAQ,CAACS,gBAAgBA,eAAeN,OAAO;QAC3D,MAAMO,SAASf,MAAMW,wBAAAA,KAAMX,KAAK;QAChC,IAAIe,SAAS,GAAGN,IAAI,CAACC,UAAU,GAAG;YAAEV,OAAOe;YAAQD;QAAY;IACjE;IACA,OAAOL;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,uBACdpB,IAA8B,EAC9BY,KAAa;IAEb,2EAA2E;IAC3E,wEAAwE;IACxE,IAAIb,iBAAiBC,OAAO,OAAO;IACnC,MAAMqB,OAAOL,OAAOM,MAAM,CAACZ,mBAAmBV,wBAAAA,KAAMW,KAAK,EAAEC,QAAQW,MAAM,CACvE,CAACC,KAAKT,OAASS,MAAMT,KAAKX,KAAK,EAC/B;IAEF,OAAOG,KAAKkB,GAAG,CAAC,GAAGrB,MAAMJ,wBAAAA,KAAM0B,YAAY,IAAIL;AACjD;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASM,wBACd3B,IAA8B,EAC9Bc,SAAiB,EACjB,gEAAgE;AAChE,yEAAyE;AACzE,kEAAkE;AAClEc,MAAc;;IAEd,4EAA4E;IAC5E,2EAA2E;IAC3E,8EAA8E;IAC9E,uEAAuE;IACvE,MAAMb,OAAO,SAACf,wBAAAA,KAAMW,KAAK,mBAAI,CAAC,EAAE,CAACG,UAAU;IAC3C,OAAOP,KAAKsB,GAAG,CAACzB,MAAMW,wBAAAA,KAAMX,KAAK,GAAGA,MAAMJ,wBAAAA,KAAM0B,YAAY;AAC9D"}
@@ -27,7 +27,8 @@ import { recordContactRefund } from "./contact-refund.js";
27
27
  import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/email-revenue-attribution";
28
28
  import { paymentRiskEventFrom } from "@aglyn/aglyn/app-utils/payment-risk";
29
29
  import { recordPaymentRiskOnRecord } from "@aglyn/tenant-data-admin/server/payment-risk-record";
30
- import { applyGiftCardRiskToOrder, notifyGiftCardHold } from "./gift-card-risk.js";
30
+ import { isTransientStripeStatus, reverseDestinationTransfer } from "@aglyn/tenant-data-admin/server/stripe-transfer-reversal";
31
+ import { applyExternalRefundToGiftCards, applyGiftCardRiskToOrder, notifyGiftCardHold } from "./gift-card-risk.js";
31
32
  import { mintDownloadToken, tokenSigningSecret } from "./download.js";
32
33
  import { alertLowStockCrossing } from "./low-stock.js";
33
34
  import { decrementVariantStock } from "./reserve-stock.js";
@@ -306,6 +307,46 @@ async function findOrderForDispute(paymentIntentId) {
306
307
  evidenceDueByMs: dueBy * 1000
307
308
  } : {});
308
309
  }
310
+ /**
311
+ * Record, once, the part of `charge.amount_refunded` the order does not
312
+ * already account for (AGL-3363): the console's own refunds are reserved in
313
+ * `refundedCents` before Stripe is asked, and earlier Dashboard refunds are
314
+ * in `externalRefundedCents`. Returns the new amount and the order's lines,
315
+ * or null when there is nothing new — a console refund's own event, or a
316
+ * redelivery. One transaction, so two deliveries cannot both count it.
317
+ */ async function recordExternalRefund(orderRef, charge) {
318
+ var _ref;
319
+ const stripeRefundedCents = Math.max(0, Math.round(Number((_ref = charge == null ? void 0 : charge.amount_refunded) != null ? _ref : 0)));
320
+ if (!(stripeRefundedCents > 0)) return null;
321
+ return firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
322
+ var _fresh_data, _order_refundedCents, _data_externalRefundedCents, _order_lineItems;
323
+ const fresh = await transaction.get(orderRef);
324
+ if (!fresh.exists) return null;
325
+ const data = (_fresh_data = fresh.data()) != null ? _fresh_data : {};
326
+ const order = CommerceModel.liftLegacyOrder(data);
327
+ const consoleCents = Math.max(0, Number((_order_refundedCents = order.refundedCents) != null ? _order_refundedCents : 0));
328
+ const externalCents = Math.max(0, Number((_data_externalRefundedCents = data['externalRefundedCents']) != null ? _data_externalRefundedCents : 0));
329
+ const newCents = stripeRefundedCents - consoleCents - externalCents;
330
+ if (!(newCents > 0)) return null;
331
+ transaction.set(orderRef, {
332
+ externalRefundedCents: externalCents + newCents,
333
+ timeline: CommerceModel.appendOrderEvent(order, 'refund', `$${(newCents / 100).toFixed(2)} refunded outside the dashboard (in Stripe)`)
334
+ }, {
335
+ merge: true
336
+ });
337
+ return {
338
+ newCents,
339
+ fullyRefunded: (charge == null ? void 0 : charge.refunded) === true,
340
+ lines: ((_order_lineItems = order.lineItems) != null ? _order_lineItems : []).map((line)=>{
341
+ var _line_productId, _line_unitAmountCents, _line_quantity;
342
+ return {
343
+ productId: String((_line_productId = line.productId) != null ? _line_productId : ''),
344
+ totalCents: Math.round(Number((_line_unitAmountCents = line.unitAmountCents) != null ? _line_unitAmountCents : 0)) * Math.max(1, Math.round(Number((_line_quantity = line.quantity) != null ? _line_quantity : 1)))
345
+ };
346
+ })
347
+ };
348
+ });
349
+ }
309
350
  /**
310
351
  * Apply a gift-card risk action to the cards an order issued (AGL-3363). The
311
352
  * order doc id is the Checkout Session id every issued card carries as its
@@ -596,9 +637,6 @@ async function findOrderForDispute(paymentIntentId) {
596
637
  link: `/${hostId}/products`
597
638
  });
598
639
  }
599
- /** Stripe failures a redelivery can actually fix. */ function isTransientStripeStatus(status) {
600
- return status === 429 || status >= 500;
601
- }
602
640
  /** One authorized GET against the Stripe API, body parsed either way. */ async function stripeGet(url, stripeKey) {
603
641
  const response = await fetch(url, {
604
642
  headers: {
@@ -1390,8 +1428,7 @@ async function findOrderForDispute(paymentIntentId) {
1390
1428
  * notification is keyed on it, so a non-zero answer must mean "the write
1391
1429
  * happened HERE, and nobody has told the merchant yet".
1392
1430
  */ async function reverseSellerShare(orderRef, dispute) {
1393
- var _ref, _snapshot_data, _stored_reversedCents, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8;
1394
- var _charge_body, _charge_body1, _transfer_body_reversals, _transfer_body, _transfer_body1, _transfer_body2;
1431
+ var _ref, _snapshot_data, _stored_reversedCents, _ref1;
1395
1432
  const disputeId = String((_ref = dispute == null ? void 0 : dispute.id) != null ? _ref : '');
1396
1433
  if (!disputeId) return 0;
1397
1434
  const snapshot = await orderRef.get();
@@ -1448,103 +1485,42 @@ async function findOrderForDispute(paymentIntentId) {
1448
1485
  return true;
1449
1486
  });
1450
1487
  };
1451
- const chargeId = String((_ref1 = dispute == null ? void 0 : dispute.charge) != null ? _ref1 : '');
1452
- if (!chargeId) {
1453
- console.error('Dispute carries no charge; seller share not reversed', {
1454
- disputeId
1455
- });
1456
- await settle(0, null, 'Seller share not reversed — no charge on the dispute');
1457
- return 0;
1458
- }
1459
- const charge = await stripeGet(`https://api.stripe.com/v1/charges/${chargeId}`, stripeKey);
1460
- if (!charge.ok) {
1461
- var _charge_body2;
1462
- if (isTransientStripeStatus(charge.status)) {
1463
- throw new Error(`Stripe charge read failed (${charge.status}) for dispute ${disputeId}`);
1488
+ // The Stripe half is the shared seam (AGL-3363): the booking deposit's lost
1489
+ // dispute pulls back through the same function, so the two cannot drift.
1490
+ const outcome = await reverseDestinationTransfer({
1491
+ kind: 'dispute',
1492
+ id: disputeId,
1493
+ amountCents: principalCents,
1494
+ chargeId: String((_ref1 = dispute == null ? void 0 : dispute.charge) != null ? _ref1 : ''),
1495
+ metadata: {
1496
+ orderId: orderRef.id
1464
1497
  }
1465
- console.error('Stripe refused the charge read; seller share not reversed', (_charge_body2 = charge.body) == null ? void 0 : _charge_body2.error);
1466
- await settle(0, null, 'Seller share not reversed — charge not found at Stripe');
1467
- return 0;
1468
- }
1469
- const transferId = String((_ref2 = (_charge_body = charge.body) == null ? void 0 : _charge_body.transfer) != null ? _ref2 : '');
1470
- const chargeAmountCents = Math.round(Number((_ref3 = (_charge_body1 = charge.body) == null ? void 0 : _charge_body1.amount) != null ? _ref3 : 0));
1471
- if (!transferId || !(chargeAmountCents > 0)) {
1472
- // An order from before destination charges, or a charge Stripe holds no
1473
- // transfer for. Logged, recorded, let go.
1474
- console.error('No transfer on the disputed charge; seller share not reversed', {
1475
- disputeId,
1476
- chargeId
1477
- });
1478
- await settle(0, null, 'Seller share not reversed — no transfer on the charge');
1479
- return 0;
1480
- }
1481
- const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${transferId}`, stripeKey);
1482
- if (!transfer.ok) {
1483
- var _transfer_body3;
1484
- if (isTransientStripeStatus(transfer.status)) {
1485
- throw new Error(`Stripe transfer read failed (${transfer.status}) for dispute ${disputeId}`);
1486
- }
1487
- console.error('Stripe refused the transfer read; seller share not reversed', (_transfer_body3 = transfer.body) == null ? void 0 : _transfer_body3.error);
1488
- await settle(0, null, 'Seller share not reversed — transfer not found at Stripe');
1489
- return 0;
1490
- }
1491
- // The crash window's backstop: the POST landed on a previous delivery and
1492
- // the record did not. Adopt what exists rather than creating a second one.
1493
- const existing = ((_ref4 = (_transfer_body = transfer.body) == null ? void 0 : (_transfer_body_reversals = _transfer_body.reversals) == null ? void 0 : _transfer_body_reversals.data) != null ? _ref4 : []).find((item)=>{
1494
- var _ref;
1495
- var _item_metadata;
1496
- return String((_ref = item == null ? void 0 : (_item_metadata = item.metadata) == null ? void 0 : _item_metadata.disputeId) != null ? _ref : '') === disputeId;
1498
+ }, {
1499
+ stripeKey
1497
1500
  });
1498
- if (existing) {
1499
- var _existing_amount, _existing_id;
1500
- const adoptedCents = Math.round(Number((_existing_amount = existing.amount) != null ? _existing_amount : 0));
1501
- // Announced on adoption too: the delivery that created this reversal died
1502
- // before recording it, so it died before notifying. The money left the
1503
- // connected account either way and the merchant has not been told yet.
1504
- const wrote = await settle(adoptedCents, String((_existing_id = existing.id) != null ? _existing_id : ''), `$${(adoptedCents / 100).toFixed(2)} seller share reversed for lost dispute`);
1505
- return wrote ? adoptedCents : 0;
1506
- }
1507
- const transferCents = Math.round(Number((_ref5 = (_transfer_body1 = transfer.body) == null ? void 0 : _transfer_body1.amount) != null ? _ref5 : 0));
1508
- const alreadyReversedCents = Math.round(Number((_ref6 = (_transfer_body2 = transfer.body) == null ? void 0 : _transfer_body2.amount_reversed) != null ? _ref6 : 0));
1509
- const remainingCents = Math.max(0, transferCents - alreadyReversedCents);
1510
- const shareCents = Math.min(chargeAmountCents > 0 ? Math.floor(principalCents * transferCents / chargeAmountCents) : 0, remainingCents);
1511
- if (!(shareCents > 0)) {
1512
- console.error('Transfer has nothing left to reverse; seller share not reversed', {
1501
+ if (outcome.kind === 'skipped') return 0;
1502
+ if (outcome.kind === 'not-reversed') {
1503
+ console.error(outcome.reason === 'transfer-fully-reversed' ? 'Transfer has nothing left to reverse; seller share not reversed' : 'Seller share not reversed', {
1513
1504
  disputeId,
1514
- transferId,
1515
- transferCents,
1516
- alreadyReversedCents
1505
+ reason: outcome.reason
1517
1506
  });
1518
- await settle(0, null, 'Seller share already reversed on the transfer — nothing left to pull back');
1507
+ await settle(0, null, SELLER_SHARE_NOT_REVERSED_NOTE[outcome.reason]);
1519
1508
  return 0;
1520
1509
  }
1521
- const params = new URLSearchParams({
1522
- amount: String(shareCents),
1523
- 'metadata[disputeId]': disputeId,
1524
- 'metadata[orderId]': orderRef.id
1525
- });
1526
- const response = await fetch(`https://api.stripe.com/v1/transfers/${transferId}/reversals`, {
1527
- method: 'POST',
1528
- headers: {
1529
- Authorization: `Bearer ${stripeKey}`,
1530
- 'Content-Type': 'application/x-www-form-urlencoded',
1531
- 'Idempotency-Key': `dispute-reversal-${disputeId}`
1532
- },
1533
- body: params.toString()
1534
- });
1535
- const reversal = await response.json().catch(()=>null);
1536
- if (!response.ok) {
1537
- if (isTransientStripeStatus(response.status)) {
1538
- throw new Error(`Stripe transfer reversal failed (${response.status}) for dispute ${disputeId}`);
1539
- }
1540
- console.error('Stripe refused the transfer reversal', reversal == null ? void 0 : reversal.error);
1541
- await settle(0, null, 'Seller share not reversed — Stripe refused the reversal');
1542
- return 0;
1543
- }
1544
- const reversedTransferCents = Math.round(Number((_ref7 = reversal == null ? void 0 : reversal.amount) != null ? _ref7 : shareCents));
1545
- const wrote = await settle(reversedTransferCents, String((_ref8 = reversal == null ? void 0 : reversal.id) != null ? _ref8 : ''), `$${(reversedTransferCents / 100).toFixed(2)} seller share reversed for lost dispute`);
1546
- return wrote ? reversedTransferCents : 0;
1510
+ // Announced on adoption too: the delivery that created an adopted reversal
1511
+ // died before recording it, so it died before notifying. The money left the
1512
+ // connected account either way and the merchant has not been told yet.
1513
+ const wrote = await settle(outcome.cents, outcome.reversalId, `$${(outcome.cents / 100).toFixed(2)} seller share reversed for lost dispute`);
1514
+ return wrote ? outcome.cents : 0;
1547
1515
  }
1516
+ /** The order timeline's words for each final reason nothing was pulled back. */ const SELLER_SHARE_NOT_REVERSED_NOTE = {
1517
+ 'no-charge-on-cause': 'Seller share not reversed — no charge on the dispute',
1518
+ 'charge-read-refused': 'Seller share not reversed — charge not found at Stripe',
1519
+ 'no-transfer-on-charge': 'Seller share not reversed — no transfer on the charge',
1520
+ 'transfer-read-refused': 'Seller share not reversed — transfer not found at Stripe',
1521
+ 'transfer-fully-reversed': 'Seller share already reversed on the transfer — nothing left to pull back',
1522
+ 'reversal-refused': 'Seller share not reversed — Stripe refused the reversal'
1523
+ };
1548
1524
  /**
1549
1525
  * Commerce sections of the platform Stripe webhook (AGL-418): relocated
1550
1526
  * verbatim from the console route — subscriptions sync, reservations,
@@ -3911,6 +3887,48 @@ async function findOrderForDispute(paymentIntentId) {
3911
3887
  }
3912
3888
  }
3913
3889
  }
3890
+ // A refund made OUTSIDE the console (AGL-3363) — in the Stripe Dashboard.
3891
+ // `refund.ts` reserves `refundedCents` on the order before it asks Stripe,
3892
+ // so by the time its own `charge.refunded` arrives the order already counts
3893
+ // it; whatever Stripe has refunded beyond the order's count, and beyond the
3894
+ // Dashboard refunds already recorded here, is new. The order's gift cards
3895
+ // then follow the in-console rules (`applyExternalRefundToGiftCards`), and
3896
+ // the refund is written on the order's timeline. Nothing else is moved: a
3897
+ // Dashboard refund is staff's act, and they chose its transfer reversal.
3898
+ if (type === 'charge.refunded') {
3899
+ var _ref83, _ref84;
3900
+ var _object_payment_intent, _snapshot_ref_parent_parent;
3901
+ const paymentIntentId = typeof (object == null ? void 0 : object.payment_intent) === 'string' ? object.payment_intent : String((_ref83 = object == null ? void 0 : (_object_payment_intent = object.payment_intent) == null ? void 0 : _object_payment_intent.id) != null ? _ref83 : '');
3902
+ const lookup = paymentIntentId ? await findOrderForDispute(paymentIntentId) : {
3903
+ kind: 'not-ours'
3904
+ };
3905
+ if (lookup.kind !== 'order') return;
3906
+ const snapshot = lookup.snapshot;
3907
+ const hostId = String((_ref84 = (_snapshot_ref_parent_parent = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent.id) != null ? _ref84 : '');
3908
+ const external = await recordExternalRefund(snapshot.ref, object);
3909
+ if (external && hostId && snapshot.ref.parent.parent) {
3910
+ const outcome = await applyExternalRefundToGiftCards({
3911
+ firestore: firebaseAdmin.app().firestore(),
3912
+ hostRef: snapshot.ref.parent.parent,
3913
+ orderId: snapshot.id,
3914
+ lines: external.lines,
3915
+ refundCents: external.newCents,
3916
+ fullyRefunded: external.fullyRefunded
3917
+ });
3918
+ if (outcome.kind === 'frozen-for-review') {
3919
+ await notifyGiftCardHold({
3920
+ hostId,
3921
+ orderLabel: `Order ${snapshot.id}`,
3922
+ cards: outcome.cards,
3923
+ reason: 'refund-review'
3924
+ }, notifyHostManagers);
3925
+ }
3926
+ }
3927
+ return {
3928
+ claimed: true,
3929
+ hostId
3930
+ };
3931
+ }
3914
3932
  // Fraud signals on a store's sale (AGL-3360): an issuer's early fraud
3915
3933
  // warning and a Radar review. The merchant decides what to do for their
3916
3934
  // own customer, so the signal lands on the ORDER — a timeline line and the
@@ -3922,14 +3940,14 @@ async function findOrderForDispute(paymentIntentId) {
3922
3940
  // merchant; both report the site so the seller's fraud ledger can name it.
3923
3941
  const risk = paymentRiskEventFrom(type, object);
3924
3942
  if (risk && risk.signal.kind !== 'dispute') {
3925
- var _ref83, _snapshot_data;
3926
- var _snapshot_ref_parent_parent;
3943
+ var _ref85, _snapshot_data;
3944
+ var _snapshot_ref_parent_parent1;
3927
3945
  const lookup = risk.paymentIntentId ? await findOrderForDispute(risk.paymentIntentId) : {
3928
3946
  kind: 'not-ours'
3929
3947
  };
3930
3948
  if (lookup.kind !== 'order') return;
3931
3949
  const snapshot = lookup.snapshot;
3932
- const hostId = String((_ref83 = (_snapshot_ref_parent_parent = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent.id) != null ? _ref83 : '');
3950
+ const hostId = String((_ref85 = (_snapshot_ref_parent_parent1 = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent1.id) != null ? _ref85 : '');
3933
3951
  const order = CommerceModel.liftLegacyOrder((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
3934
3952
  await recordPaymentRiskOnRecord({
3935
3953
  ref: snapshot.ref,
@@ -3987,8 +4005,8 @@ async function findOrderForDispute(paymentIntentId) {
3987
4005
  // finds). AGL-1554 will add its own `closed` branch for the marketplace
3988
4006
  // side; the two coexist because each self-selects by finding its own record.
3989
4007
  if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
3990
- var _dispute_payment_intent, _ref84;
3991
- var _snapshot_ref_parent_parent1;
4008
+ var _dispute_payment_intent, _ref86;
4009
+ var _snapshot_ref_parent_parent2;
3992
4010
  const dispute = object != null ? object : {};
3993
4011
  const paymentIntentId = String((_dispute_payment_intent = dispute.payment_intent) != null ? _dispute_payment_intent : '');
3994
4012
  const lookup = paymentIntentId ? await findOrderForDispute(paymentIntentId) : {
@@ -4013,9 +4031,9 @@ async function findOrderForDispute(paymentIntentId) {
4013
4031
  const claimed = lookup.kind === 'order' || lookup.kind === 'unresolved';
4014
4032
  const snapshot = lookup.kind === 'order' ? lookup.snapshot : null;
4015
4033
  if (snapshot) {
4016
- var _ref85;
4017
- var _snapshot_ref_parent_parent2;
4018
- const hostId = String((_ref85 = (_snapshot_ref_parent_parent2 = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent2.id) != null ? _ref85 : '');
4034
+ var _ref87;
4035
+ var _snapshot_ref_parent_parent3;
4036
+ const hostId = String((_ref87 = (_snapshot_ref_parent_parent3 = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent3.id) != null ? _ref87 : '');
4019
4037
  if (type === 'charge.dispute.created') {
4020
4038
  // A chargeback's gift cards are frozen while it is open (AGL-3363).
4021
4039
  await notifyGiftCardHold({
@@ -4042,11 +4060,11 @@ async function findOrderForDispute(paymentIntentId) {
4042
4060
  });
4043
4061
  }
4044
4062
  } else {
4045
- var _ref86, _ref87;
4063
+ var _ref88, _ref89;
4046
4064
  // Lost: the money went back, so the cards it bought are voided. Won
4047
4065
  // (or closed on a warning): the dispute's freeze lifts; a freeze a
4048
4066
  // fraud warning or review put on stays for the merchant (AGL-3363).
4049
- await applyOrderGiftCardRisk(snapshot, String((_ref86 = dispute == null ? void 0 : dispute.status) != null ? _ref86 : '') === 'lost' ? {
4067
+ await applyOrderGiftCardRisk(snapshot, String((_ref88 = dispute == null ? void 0 : dispute.status) != null ? _ref88 : '') === 'lost' ? {
4050
4068
  kind: 'void',
4051
4069
  reason: 'dispute-lost'
4052
4070
  } : {
@@ -4110,7 +4128,7 @@ async function findOrderForDispute(paymentIntentId) {
4110
4128
  // transiently-failed reversal gets its retry — and gated inside on
4111
4129
  // the order's own `reversedTransferCents` marker, so it runs at most
4112
4130
  // once per dispute however many times Stripe delivers.
4113
- if (String((_ref87 = dispute == null ? void 0 : dispute.status) != null ? _ref87 : '') === 'lost') {
4131
+ if (String((_ref89 = dispute == null ? void 0 : dispute.status) != null ? _ref89 : '') === 'lost') {
4114
4132
  const pulledBackCents = await reverseSellerShare(snapshot.ref, dispute);
4115
4133
  // The merchant-experience half of the AGL-1794 decision, and not an
4116
4134
  // optional extra: the reversal takes money out of the connected
@@ -4140,7 +4158,7 @@ async function findOrderForDispute(paymentIntentId) {
4140
4158
  return _extends({
4141
4159
  claimed
4142
4160
  }, snapshot ? {
4143
- hostId: String((_ref84 = (_snapshot_ref_parent_parent1 = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent1.id) != null ? _ref84 : '')
4161
+ hostId: String((_ref86 = (_snapshot_ref_parent_parent2 = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent2.id) != null ? _ref86 : '')
4144
4162
  } : {});
4145
4163
  }
4146
4164
  };