@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.
@@ -67,4 +67,46 @@ export declare function notifyGiftCardHold(input: {
67
67
  cards: number;
68
68
  reason: CommerceModel.GiftCardFreezeReason;
69
69
  }, notify: GiftCardHoldNotifier): Promise<void>;
70
+ /** One line of the order, as far as matching a refund to it goes. */
71
+ export interface GiftCardRefundLine {
72
+ productId: string;
73
+ /** What the line cost in total: unit price × quantity. */
74
+ totalCents: number;
75
+ }
76
+ /** What a refund made outside the console did to the order's cards. */
77
+ export type ExternalRefundGiftCardOutcome = {
78
+ kind: 'no-cards';
79
+ } | {
80
+ kind: 'voided';
81
+ cards: number;
82
+ }
83
+ /** Matched to a line that bought no gift card: nothing to take back. */
84
+ | {
85
+ kind: 'matched-other-line';
86
+ } | {
87
+ kind: 'frozen-for-review';
88
+ cards: number;
89
+ };
90
+ /**
91
+ * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the
92
+ * gift cards the order bought, the way an in-console refund does (AGL-3363).
93
+ *
94
+ * - A charge refunded in full voids every card the order issued.
95
+ * - A partial refund is matched to lines by amount: when it equals exactly
96
+ * one line's total, or exactly the total of every gift-card line, the
97
+ * cards those lines bought are voided (a line that bought no card voids
98
+ * nothing, which is the refund of a mug).
99
+ * - Anything else cannot be told apart — two lines of the same price, a
100
+ * goodwill amount — so the order's cards are FROZEN for the merchant to
101
+ * review rather than voided or left spendable.
102
+ */
103
+ export declare function applyExternalRefundToGiftCards(input: {
104
+ firestore: FirebaseFirestore.Firestore;
105
+ hostRef: FirebaseFirestore.DocumentReference;
106
+ orderId: string;
107
+ lines: readonly GiftCardRefundLine[];
108
+ refundCents: number;
109
+ fullyRefunded: boolean;
110
+ nowMs?: number;
111
+ }): Promise<ExternalRefundGiftCardOutcome>;
70
112
  export {};
@@ -102,7 +102,8 @@
102
102
  /** What a freeze's reason means, in the merchant's words. */ const FREEZE_CAUSE = {
103
103
  'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',
104
104
  'radar-review': 'the payment is being reviewed for fraud',
105
- dispute: 'the payment is disputed'
105
+ dispute: 'the payment is disputed',
106
+ 'refund-review': 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards'
106
107
  };
107
108
  /**
108
109
  * Tell the site's managers that an order's gift cards were frozen
@@ -115,9 +116,105 @@
115
116
  await notify(input.hostId, {
116
117
  type: 'content.order',
117
118
  title: `${input.cards === 1 ? 'A gift card is' : 'Gift cards are'} on hold`,
118
- body: `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` + `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. If you are ` + 'satisfied the payment is genuine, release it from Gift cards. If the ' + 'payment is refunded or the dispute is lost, the balance is voided for you.',
119
+ body: `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` + `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. ` + (input.reason === 'refund-review' ? 'If the customer ' : 'If you are ') + (input.reason === 'refund-review' ? 'kept the gift cards, release them from Gift cards; if the refund was ' + 'for them, void them there.' : 'satisfied the payment is genuine, release it from Gift cards. If the ' + 'payment is refunded or the dispute is lost, the balance is voided for you.'),
119
120
  link: `/${input.hostId}/products`
120
121
  }).catch(()=>undefined);
121
122
  }
123
+ /**
124
+ * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the
125
+ * gift cards the order bought, the way an in-console refund does (AGL-3363).
126
+ *
127
+ * - A charge refunded in full voids every card the order issued.
128
+ * - A partial refund is matched to lines by amount: when it equals exactly
129
+ * one line's total, or exactly the total of every gift-card line, the
130
+ * cards those lines bought are voided (a line that bought no card voids
131
+ * nothing, which is the refund of a mug).
132
+ * - Anything else cannot be told apart — two lines of the same price, a
133
+ * goodwill amount — so the order's cards are FROZEN for the merchant to
134
+ * review rather than voided or left spendable.
135
+ */ export async function applyExternalRefundToGiftCards(input) {
136
+ const { firestore, hostRef, orderId } = input;
137
+ let cardProducts;
138
+ try {
139
+ var _snapshot_docs;
140
+ const snapshot = await hostRef.collection('giftCards').where('orderId', '==', orderId).limit(CARDS_PER_ORDER_READ).get();
141
+ const live = ((_snapshot_docs = snapshot.docs) != null ? _snapshot_docs : []).filter((card)=>{
142
+ var _ref;
143
+ var _card_data;
144
+ return !(Number((_ref = card.get == null ? void 0 : card.get.call(card, 'voidedAtMs')) != null ? _ref : card.data == null ? void 0 : (_card_data = card.data.call(card)) == null ? void 0 : _card_data['voidedAtMs']) > 0);
145
+ });
146
+ if (live.length === 0) return {
147
+ kind: 'no-cards'
148
+ };
149
+ cardProducts = live.map((card)=>{
150
+ var _ref, _ref1;
151
+ var _card_data;
152
+ return String((_ref = (_ref1 = card.get == null ? void 0 : card.get.call(card, 'productId')) != null ? _ref1 : card.data == null ? void 0 : (_card_data = card.data.call(card)) == null ? void 0 : _card_data['productId']) != null ? _ref : '');
153
+ });
154
+ } catch (error) {
155
+ console.error('[gift-card-risk] could not read the order’s cards', orderId, error);
156
+ return {
157
+ kind: 'no-cards'
158
+ };
159
+ }
160
+ const apply = (action)=>applyGiftCardRiskToOrder({
161
+ firestore,
162
+ hostRef,
163
+ orderId,
164
+ action,
165
+ nowMs: input.nowMs
166
+ });
167
+ if (input.fullyRefunded) {
168
+ return {
169
+ kind: 'voided',
170
+ cards: await apply({
171
+ kind: 'void',
172
+ reason: 'refund'
173
+ })
174
+ };
175
+ }
176
+ const refundCents = Math.round(input.refundCents);
177
+ const single = input.lines.filter((line)=>line.totalCents === refundCents);
178
+ const singleProducts = new Set(single.map((line)=>line.productId));
179
+ if (single.length > 0 && singleProducts.size === 1) {
180
+ const [productId] = [
181
+ ...singleProducts
182
+ ];
183
+ if (!cardProducts.includes(productId)) return {
184
+ kind: 'matched-other-line'
185
+ };
186
+ return {
187
+ kind: 'voided',
188
+ cards: await apply({
189
+ kind: 'void',
190
+ reason: 'refund',
191
+ productIds: [
192
+ productId
193
+ ]
194
+ })
195
+ };
196
+ }
197
+ const giftLines = input.lines.filter((line)=>cardProducts.includes(line.productId));
198
+ const giftTotal = giftLines.reduce((sum, line)=>sum + line.totalCents, 0);
199
+ if (single.length === 0 && giftLines.length > 0 && giftTotal === refundCents) {
200
+ return {
201
+ kind: 'voided',
202
+ cards: await apply({
203
+ kind: 'void',
204
+ reason: 'refund',
205
+ productIds: [
206
+ ...new Set(giftLines.map((line)=>line.productId))
207
+ ]
208
+ })
209
+ };
210
+ }
211
+ return {
212
+ kind: 'frozen-for-review',
213
+ cards: await apply({
214
+ kind: 'freeze',
215
+ reason: 'refund-review'
216
+ })
217
+ };
218
+ }
122
219
 
123
220
  //# sourceMappingURL=gift-card-risk.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-card-risk.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 * THE CARDS AN ORDER ISSUED, WHEN ITS PAYMENT GOES BAD (AGL-3363).\n *\n * See \"GIFT CARDS ARE A CASH-OUT\" in `model/commerce-gift-cards.ts` for the\n * rules. This is the one place that applies them to the cards an order\n * minted — found by `orderId`, which the cart's completion stamps on every\n * card it issues (the order's id is its Checkout Session id) — so the\n * webhook's fraud-signal and dispute branches and the refund route share it.\n *\n * Every write is per card, in its own transaction, and best-effort: a card\n * that fails to freeze is logged and the rest carry on. The caller's money\n * movement (a refund that already left, a dispute Stripe already opened) is\n * never failed by this.\n *=========================================*/\n\nimport type * as CommerceModel from '../model'\n\n/** What happened to the payment that bought the cards. */\nexport type GiftCardRiskAction =\n | { kind: 'freeze'; reason: CommerceModel.GiftCardFreezeReason }\n /** A dispute won, or a merchant's release: lifts a freeze for `reason` only. */\n | { kind: 'release'; reason?: CommerceModel.GiftCardFreezeReason }\n | {\n kind: 'void'\n reason: CommerceModel.GiftCardVoidReason\n /**\n * Only the cards bought as these products, for a partial refund that\n * withdrew some lines. Absent voids every card the order issued. A card\n * issued before `productId` was stamped is voided only by a full void.\n */\n productIds?: readonly string[]\n }\n\n/** A host document reference, as far as this module reads it. */\ntype HostRef = FirebaseFirestore.DocumentReference\n\nconst CARDS_PER_ORDER_READ = 500\n\n/**\n * Apply `action` to every card `orderId` issued on `hostRef`. Returns how\n * many cards changed. Never throws.\n */\nexport async function applyGiftCardRiskToOrder(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: HostRef\n orderId: string\n action: GiftCardRiskAction\n nowMs?: number\n}): Promise<number> {\n const { firestore, hostRef, orderId, action } = input\n if (!orderId) return 0\n const nowMs = input.nowMs ?? Date.now()\n let cards: FirebaseFirestore.QueryDocumentSnapshot[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n cards = snapshot.docs ?? []\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return 0\n }\n let changed = 0\n for (const card of cards) {\n try {\n const moved = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(card.ref)\n const data = (fresh.data() ?? {}) as CommerceModel.HostGiftCard & {\n productId?: string\n }\n if (Number(data.voidedAtMs) > 0) return false\n if (action.kind === 'freeze') {\n if (Number(data.frozenAtMs) > 0) return false\n transaction.set(\n card.ref,\n { frozenAtMs: nowMs, frozenReason: action.reason },\n { merge: true },\n )\n return true\n }\n if (action.kind === 'release') {\n if (!(Number(data.frozenAtMs) > 0)) return false\n if (action.reason && data.frozenReason !== action.reason) return false\n transaction.set(\n card.ref,\n { frozenAtMs: null, frozenReason: null, releasedAtMs: nowMs },\n { merge: true },\n )\n return true\n }\n if (action.productIds) {\n if (!data.productId || !action.productIds.includes(data.productId)) {\n return false\n }\n }\n const balance = Math.max(0, Math.round(Number(data.balanceCents) || 0))\n transaction.set(\n card.ref,\n {\n balanceCents: 0,\n voidedAtMs: nowMs,\n voidedReason: action.reason,\n // What was taken back, so the merchant can see it and reissue by\n // hand if the reversal is itself reversed.\n voidedBalanceCents: balance,\n },\n { merge: true },\n )\n return true\n })\n if (moved) changed += 1\n } catch (error) {\n console.error('[gift-card-risk] card update failed', card.id, error)\n }\n }\n return changed\n}\n\n/** What a freeze's reason means, in the merchant's words. */\nconst FREEZE_CAUSE: Record<CommerceModel.GiftCardFreezeReason, string> = {\n 'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',\n 'radar-review': 'the payment is being reviewed for fraud',\n dispute: 'the payment is disputed',\n}\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type GiftCardHoldNotifier = (\n hostId: string,\n payload: { type: 'content.order'; title: string; body: string; link: string },\n) => Promise<void>\n\n/**\n * Tell the site's managers that an order's gift cards were frozen\n * (AGL-3363). The one notifier for this kind of signal, so a shared\n * risk-notice seam can take it over whole. Says what happened and what to\n * do, never a rule or a number. Never throws.\n */\nexport async function notifyGiftCardHold(\n input: {\n hostId: string\n /** The order as the merchant knows it: `Order 1042`. */\n orderLabel: string\n cards: number\n reason: CommerceModel.GiftCardFreezeReason\n },\n notify: GiftCardHoldNotifier,\n): Promise<void> {\n if (!input.hostId || input.cards <= 0) return\n const plural = input.cards === 1 ? 'gift card' : 'gift cards'\n await notify(input.hostId, {\n type: 'content.order',\n title: `${input.cards === 1 ? 'A gift card is' : 'Gift cards are'} on hold`,\n body:\n `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` +\n `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. If you are ` +\n 'satisfied the payment is genuine, release it from Gift cards. If the ' +\n 'payment is refunded or the dispute is lost, the balance is voided for you.',\n link: `/${input.hostId}/products`,\n }).catch(() => undefined)\n}\n"],"names":["CARDS_PER_ORDER_READ","applyGiftCardRiskToOrder","input","firestore","hostRef","orderId","action","nowMs","Date","now","cards","snapshot","collection","where","limit","get","docs","error","console","changed","card","moved","runTransaction","transaction","fresh","ref","data","Number","voidedAtMs","kind","frozenAtMs","set","frozenReason","reason","merge","releasedAtMs","productIds","productId","includes","balance","Math","max","round","balanceCents","voidedReason","voidedBalanceCents","id","FREEZE_CAUSE","dispute","notifyGiftCardHold","notify","hostId","plural","type","title","body","orderLabel","link","catch","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-card-risk.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 * THE CARDS AN ORDER ISSUED, WHEN ITS PAYMENT GOES BAD (AGL-3363).\n *\n * See \"GIFT CARDS ARE A CASH-OUT\" in `model/commerce-gift-cards.ts` for the\n * rules. This is the one place that applies them to the cards an order\n * minted — found by `orderId`, which the cart's completion stamps on every\n * card it issues (the order's id is its Checkout Session id) — so the\n * webhook's fraud-signal and dispute branches and the refund route share it.\n *\n * Every write is per card, in its own transaction, and best-effort: a card\n * that fails to freeze is logged and the rest carry on. The caller's money\n * movement (a refund that already left, a dispute Stripe already opened) is\n * never failed by this.\n *=========================================*/\n\nimport type * as CommerceModel from '../model'\n\n/** What happened to the payment that bought the cards. */\nexport type GiftCardRiskAction =\n | { kind: 'freeze'; reason: CommerceModel.GiftCardFreezeReason }\n /** A dispute won, or a merchant's release: lifts a freeze for `reason` only. */\n | { kind: 'release'; reason?: CommerceModel.GiftCardFreezeReason }\n | {\n kind: 'void'\n reason: CommerceModel.GiftCardVoidReason\n /**\n * Only the cards bought as these products, for a partial refund that\n * withdrew some lines. Absent voids every card the order issued. A card\n * issued before `productId` was stamped is voided only by a full void.\n */\n productIds?: readonly string[]\n }\n\n/** A host document reference, as far as this module reads it. */\ntype HostRef = FirebaseFirestore.DocumentReference\n\nconst CARDS_PER_ORDER_READ = 500\n\n/**\n * Apply `action` to every card `orderId` issued on `hostRef`. Returns how\n * many cards changed. Never throws.\n */\nexport async function applyGiftCardRiskToOrder(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: HostRef\n orderId: string\n action: GiftCardRiskAction\n nowMs?: number\n}): Promise<number> {\n const { firestore, hostRef, orderId, action } = input\n if (!orderId) return 0\n const nowMs = input.nowMs ?? Date.now()\n let cards: FirebaseFirestore.QueryDocumentSnapshot[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n cards = snapshot.docs ?? []\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return 0\n }\n let changed = 0\n for (const card of cards) {\n try {\n const moved = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(card.ref)\n const data = (fresh.data() ?? {}) as CommerceModel.HostGiftCard & {\n productId?: string\n }\n if (Number(data.voidedAtMs) > 0) return false\n if (action.kind === 'freeze') {\n if (Number(data.frozenAtMs) > 0) return false\n transaction.set(\n card.ref,\n { frozenAtMs: nowMs, frozenReason: action.reason },\n { merge: true },\n )\n return true\n }\n if (action.kind === 'release') {\n if (!(Number(data.frozenAtMs) > 0)) return false\n if (action.reason && data.frozenReason !== action.reason) return false\n transaction.set(\n card.ref,\n { frozenAtMs: null, frozenReason: null, releasedAtMs: nowMs },\n { merge: true },\n )\n return true\n }\n if (action.productIds) {\n if (!data.productId || !action.productIds.includes(data.productId)) {\n return false\n }\n }\n const balance = Math.max(0, Math.round(Number(data.balanceCents) || 0))\n transaction.set(\n card.ref,\n {\n balanceCents: 0,\n voidedAtMs: nowMs,\n voidedReason: action.reason,\n // What was taken back, so the merchant can see it and reissue by\n // hand if the reversal is itself reversed.\n voidedBalanceCents: balance,\n },\n { merge: true },\n )\n return true\n })\n if (moved) changed += 1\n } catch (error) {\n console.error('[gift-card-risk] card update failed', card.id, error)\n }\n }\n return changed\n}\n\n/** What a freeze's reason means, in the merchant's words. */\nconst FREEZE_CAUSE: Record<CommerceModel.GiftCardFreezeReason, string> = {\n 'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',\n 'radar-review': 'the payment is being reviewed for fraud',\n dispute: 'the payment is disputed',\n 'refund-review':\n 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards',\n}\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type GiftCardHoldNotifier = (\n hostId: string,\n payload: { type: 'content.order'; title: string; body: string; link: string },\n) => Promise<void>\n\n/**\n * Tell the site's managers that an order's gift cards were frozen\n * (AGL-3363). The one notifier for this kind of signal, so a shared\n * risk-notice seam can take it over whole. Says what happened and what to\n * do, never a rule or a number. Never throws.\n */\nexport async function notifyGiftCardHold(\n input: {\n hostId: string\n /** The order as the merchant knows it: `Order 1042`. */\n orderLabel: string\n cards: number\n reason: CommerceModel.GiftCardFreezeReason\n },\n notify: GiftCardHoldNotifier,\n): Promise<void> {\n if (!input.hostId || input.cards <= 0) return\n const plural = input.cards === 1 ? 'gift card' : 'gift cards'\n await notify(input.hostId, {\n type: 'content.order',\n title: `${input.cards === 1 ? 'A gift card is' : 'Gift cards are'} on hold`,\n body:\n `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` +\n `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. ` +\n (input.reason === 'refund-review' ? 'If the customer ' : 'If you are ') +\n (input.reason === 'refund-review'\n ? 'kept the gift cards, release them from Gift cards; if the refund was ' +\n 'for them, void them there.'\n : 'satisfied the payment is genuine, release it from Gift cards. If the ' +\n 'payment is refunded or the dispute is lost, the balance is voided for you.'),\n link: `/${input.hostId}/products`,\n }).catch(() => undefined)\n}\n\n/** One line of the order, as far as matching a refund to it goes. */\nexport interface GiftCardRefundLine {\n productId: string\n /** What the line cost in total: unit price × quantity. */\n totalCents: number\n}\n\n/** What a refund made outside the console did to the order's cards. */\nexport type ExternalRefundGiftCardOutcome =\n | { kind: 'no-cards' }\n | { kind: 'voided'; cards: number }\n /** Matched to a line that bought no gift card: nothing to take back. */\n | { kind: 'matched-other-line' }\n | { kind: 'frozen-for-review'; cards: number }\n\n/**\n * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the\n * gift cards the order bought, the way an in-console refund does (AGL-3363).\n *\n * - A charge refunded in full voids every card the order issued.\n * - A partial refund is matched to lines by amount: when it equals exactly\n * one line's total, or exactly the total of every gift-card line, the\n * cards those lines bought are voided (a line that bought no card voids\n * nothing, which is the refund of a mug).\n * - Anything else cannot be told apart — two lines of the same price, a\n * goodwill amount — so the order's cards are FROZEN for the merchant to\n * review rather than voided or left spendable.\n */\nexport async function applyExternalRefundToGiftCards(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: FirebaseFirestore.DocumentReference\n orderId: string\n lines: readonly GiftCardRefundLine[]\n refundCents: number\n fullyRefunded: boolean\n nowMs?: number\n}): Promise<ExternalRefundGiftCardOutcome> {\n const { firestore, hostRef, orderId } = input\n let cardProducts: string[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n const live = (snapshot.docs ?? []).filter(\n (card) => !(Number(card.get?.('voidedAtMs') ?? card.data?.()?.['voidedAtMs']) > 0),\n )\n if (live.length === 0) return { kind: 'no-cards' }\n cardProducts = live.map((card) =>\n String(card.get?.('productId') ?? card.data?.()?.['productId'] ?? ''),\n )\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return { kind: 'no-cards' }\n }\n const apply = (action: GiftCardRiskAction) =>\n applyGiftCardRiskToOrder({ firestore, hostRef, orderId, action, nowMs: input.nowMs })\n\n if (input.fullyRefunded) {\n return { kind: 'voided', cards: await apply({ kind: 'void', reason: 'refund' }) }\n }\n const refundCents = Math.round(input.refundCents)\n const single = input.lines.filter((line) => line.totalCents === refundCents)\n const singleProducts = new Set(single.map((line) => line.productId))\n if (single.length > 0 && singleProducts.size === 1) {\n const [productId] = [...singleProducts]\n if (!cardProducts.includes(productId)) return { kind: 'matched-other-line' }\n return {\n kind: 'voided',\n cards: await apply({ kind: 'void', reason: 'refund', productIds: [productId] }),\n }\n }\n const giftLines = input.lines.filter((line) => cardProducts.includes(line.productId))\n const giftTotal = giftLines.reduce((sum, line) => sum + line.totalCents, 0)\n if (single.length === 0 && giftLines.length > 0 && giftTotal === refundCents) {\n return {\n kind: 'voided',\n cards: await apply({\n kind: 'void',\n reason: 'refund',\n productIds: [...new Set(giftLines.map((line) => line.productId))],\n }),\n }\n }\n return {\n kind: 'frozen-for-review',\n cards: await apply({ kind: 'freeze', reason: 'refund-review' }),\n }\n}\n"],"names":["CARDS_PER_ORDER_READ","applyGiftCardRiskToOrder","input","firestore","hostRef","orderId","action","nowMs","Date","now","cards","snapshot","collection","where","limit","get","docs","error","console","changed","card","moved","runTransaction","transaction","fresh","ref","data","Number","voidedAtMs","kind","frozenAtMs","set","frozenReason","reason","merge","releasedAtMs","productIds","productId","includes","balance","Math","max","round","balanceCents","voidedReason","voidedBalanceCents","id","FREEZE_CAUSE","dispute","notifyGiftCardHold","notify","hostId","plural","type","title","body","orderLabel","link","catch","undefined","applyExternalRefundToGiftCards","cardProducts","live","filter","length","map","String","apply","fullyRefunded","refundCents","single","lines","line","totalCents","singleProducts","Set","size","giftLines","giftTotal","reduce","sum"],"mappings":"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"}