@aglyn/plugins-commerce 1.0.0-beta.221 → 1.0.0-beta.222
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/README.md +4 -2
- package/package.json +14 -13
- package/src/lib/components/console/catalog-organization-card.component.js +9 -0
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/discounts-card.component.js +8 -0
- package/src/lib/components/console/discounts-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.js +8 -0
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-coupons-card.component.js +8 -0
- package/src/lib/components/console/host-coupons-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +34 -70
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +7 -6
- package/src/lib/components/console/product-zones.js +5 -4
- package/src/lib/components/console/product-zones.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +86 -284
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +67 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/model/commerce-gift-cards.d.ts +26 -0
- package/src/lib/model/commerce-gift-cards.js +42 -0
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/model/commerce-orders.d.ts +0 -57
- package/src/lib/model/commerce-orders.js +0 -80
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -1
- package/src/lib/model/index.js +0 -1
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +5 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/openapi.d.ts +27 -0
- package/src/lib/server/api-v1/openapi.js +219 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/orders-and-products.d.ts +19 -0
- package/src/lib/server/api-v1/orders-and-products.js +419 -0
- package/src/lib/server/api-v1/orders-and-products.js.map +1 -0
- package/src/lib/server/billing-webhook.js +10 -13
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/download.js +4 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/fulfill-order.d.ts +52 -11
- package/src/lib/server/fulfill-order.js +6 -9
- package/src/lib/server/fulfill-order.js.map +1 -1
- package/src/lib/server/gift-card-issue.d.ts +57 -0
- package/src/lib/server/gift-card-issue.js +155 -0
- package/src/lib/server/gift-card-issue.js.map +1 -0
- package/src/lib/server/gift-cards.js +25 -84
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/membership-register.js +9 -8
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +7 -5
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +21 -0
- package/src/lib/server/paid-media-delivery.js +23 -5
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/paid-media-serve-count.d.ts +28 -0
- package/src/lib/server/paid-media-serve-count.js +46 -0
- package/src/lib/server/paid-media-serve-count.js.map +1 -0
- package/src/lib/server/person-eraser.d.ts +45 -0
- package/src/lib/server/person-eraser.js +57 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server/refund.js +15 -15
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/restock-flag.d.ts +2 -2
- package/src/lib/server/restock-flag.js +1 -1
- package/src/lib/server/restock-flag.js.map +1 -1
- package/src/lib/server/stream.js +4 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server.js +5 -21
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-person-records.d.ts +43 -0
- package/src/lib/testing/stand-in-person-records.js +63 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/transfer/gift-card-confirm-step.component.d.ts +22 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js +259 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js.map +1 -0
- package/src/lib/transfer/gift-cards-plan.d.ts +29 -0
- package/src/lib/transfer/gift-cards-plan.js +40 -0
- package/src/lib/transfer/gift-cards-plan.js.map +1 -0
- package/src/lib/transfer/gift-cards.server.d.ts +34 -0
- package/src/lib/transfer/gift-cards.server.js +296 -0
- package/src/lib/transfer/gift-cards.server.js.map +1 -0
- package/src/lib/transfer/list-filter.d.ts +29 -0
- package/src/lib/transfer/list-filter.js +112 -0
- package/src/lib/transfer/list-filter.js.map +1 -0
- package/src/lib/transfer/product-import-results.d.ts +29 -0
- package/src/lib/transfer/product-import-results.js +49 -0
- package/src/lib/transfer/product-import-results.js.map +1 -0
- package/src/lib/transfer/product-import-step.component.d.ts +30 -0
- package/src/lib/transfer/product-import-step.component.js +99 -0
- package/src/lib/transfer/product-import-step.component.js.map +1 -0
- package/src/lib/transfer/product-transfer.d.ts +211 -0
- package/src/lib/transfer/product-transfer.js +1594 -0
- package/src/lib/transfer/product-transfer.js.map +1 -0
- package/src/lib/transfer/products.server.d.ts +44 -0
- package/src/lib/transfer/products.server.js +550 -0
- package/src/lib/transfer/products.server.js.map +1 -0
- package/src/lib/transfer/records-transfer.d.ts +182 -0
- package/src/lib/transfer/records-transfer.js +1153 -0
- package/src/lib/transfer/records-transfer.js.map +1 -0
- package/src/lib/transfer/records.server.d.ts +68 -0
- package/src/lib/transfer/records.server.js +455 -0
- package/src/lib/transfer/records.server.js.map +1 -0
- package/src/lib/transfer/register-transfer-resources.d.ts +17 -0
- package/src/lib/transfer/register-transfer-resources.js +171 -0
- package/src/lib/transfer/register-transfer-resources.js.map +1 -0
- package/src/lib/transfer/register-transfer-ui.d.ts +23 -0
- package/src/lib/transfer/register-transfer-ui.js +78 -0
- package/src/lib/transfer/register-transfer-ui.js.map +1 -0
- package/src/lib/transfer/server-common.d.ts +76 -0
- package/src/lib/transfer/server-common.js +165 -0
- package/src/lib/transfer/server-common.js.map +1 -0
- package/src/lib/transfer/transfer-header-actions.component.d.ts +39 -0
- package/src/lib/transfer/transfer-header-actions.component.js +79 -0
- package/src/lib/transfer/transfer-header-actions.component.js.map +1 -0
- package/src/lib/transfer/transfer-keys.d.ts +31 -0
- package/src/lib/transfer/transfer-keys.js +56 -0
- package/src/lib/transfer/transfer-keys.js.map +1 -0
- package/src/lib/model/commerce-io.d.ts +0 -36
- package/src/lib/model/commerce-io.js +0 -245
- package/src/lib/model/commerce-io.js.map +0 -1
- package/src/lib/server/contact-refund.d.ts +0 -117
- package/src/lib/server/contact-refund.js +0 -196
- package/src/lib/server/contact-refund.js.map +0 -1
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
/**
|
|
3
|
-
* @license
|
|
4
|
-
* Copyright 2026 Aglyn LLC
|
|
5
|
-
*
|
|
6
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
-
* you may not use this file except in compliance with the License.
|
|
8
|
-
* You may obtain a copy of the License at
|
|
9
|
-
*
|
|
10
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
-
*
|
|
12
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
-
* See the License for the specific language governing permissions and
|
|
16
|
-
* limitations under the License.
|
|
17
|
-
*/ import { mergeContactInteraction, normalizeContactEmail } from "@aglyn/aglyn/server";
|
|
18
|
-
import { firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
|
|
19
|
-
// Leaf imports, not the barrel: `refund.spec.ts` and most specs in this
|
|
20
|
-
// library mock `@aglyn/tenant-data-admin` wholesale, and a permissive stub of
|
|
21
|
-
// either function would turn every case below green without a document ever
|
|
22
|
-
// having to exist.
|
|
23
|
-
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
24
|
-
import { updateExisting } from "@aglyn/tenant-data-admin/server/update-existing";
|
|
25
|
-
/**
|
|
26
|
-
* Money going BACK to a customer, recorded on their contact (AGL-1754).
|
|
27
|
-
*
|
|
28
|
-
* `upsertHostContact` accumulates `ltvCents` and nothing anywhere decremented
|
|
29
|
-
* it, so a customer who bought $500 and returned all of it read identically to
|
|
30
|
-
* one who bought $500 and kept it — and the first surface built on the field
|
|
31
|
-
* (RFM segmentation, a lifetime-value column, a best-customers list) would have
|
|
32
|
-
* ranked a serial returner among the merchant's best customers.
|
|
33
|
-
*
|
|
34
|
-
* WHAT THIS WRITES, AND WHY IT IS NOT A DECREMENT
|
|
35
|
-
*
|
|
36
|
-
* `ltvCents` is left exactly as it is and the reversal is recorded BESIDE it:
|
|
37
|
-
*
|
|
38
|
-
* | field | meaning |
|
|
39
|
-
* | -- | -- |
|
|
40
|
-
* | `ltvCents` | gross money handed over, unchanged (AGL-1748) |
|
|
41
|
-
* | `refundedCents` | gross money handed back |
|
|
42
|
-
* | `ordersCount` | orders placed, unchanged |
|
|
43
|
-
* | `refundedOrdersCount` | of those, how many ended fully reversed |
|
|
44
|
-
* | `lastRefundAtMs` | when money last went back |
|
|
45
|
-
*
|
|
46
|
-
* This is the shape AGL-1747 chose two days earlier for the same question on
|
|
47
|
-
* the orders CSV — `amountUsd` stayed gross under its existing name and
|
|
48
|
-
* `refundedUsd`/`netUsd` were appended — and it is the shape AGL-1748 named
|
|
49
|
-
* when it recorded the gross semantics in `upsert-contact.ts`. Netting in
|
|
50
|
-
* place would make a stored number mean one thing for rows written before this
|
|
51
|
-
* commit and another for rows written after, with nothing on the document to
|
|
52
|
-
* tell the two apart. Two fields cost one number's worth of storage and keep
|
|
53
|
-
* both facts, and a reader that wants the net has `ltvCents - refundedCents`.
|
|
54
|
-
*
|
|
55
|
-
* It also answers "can it go negative?" by moving the question off storage.
|
|
56
|
-
* Both figures are monotonic counters of money that actually moved in one
|
|
57
|
-
* direction, so neither can go negative. The DERIVED net can, for exactly one
|
|
58
|
-
* reason: an order placed before AGL-1748 taught POS, drafts, reservations and
|
|
59
|
-
* bookings to pass `purchaseCents` at all, whose sale was never added and whose
|
|
60
|
-
* refund is subtracted here. That is a true statement about the data, and
|
|
61
|
-
* clamping it away at write time would erase the only evidence that a purchase
|
|
62
|
-
* is missing. AGL-1753 is the backfill that reconciles it, by SETTING both
|
|
63
|
-
* fields from the orders — which already carry `refundedCents`, so whatever a
|
|
64
|
-
* reader decides here costs the rebuild nothing. A merchant-facing reader
|
|
65
|
-
* should show gross and refunded, and clamp only the net it ranks on.
|
|
66
|
-
*
|
|
67
|
-
* `ordersCount` is deliberately NOT decremented. A fully refunded order still
|
|
68
|
-
* happened, and a decrement cannot be undone by a reader that would rather
|
|
69
|
-
* count it — while `refundedOrdersCount` beside it lets RFM's frequency term
|
|
70
|
-
* net the two, or not, at the point of display. Same argument as the amount,
|
|
71
|
-
* one field along.
|
|
72
|
-
*
|
|
73
|
-
* WHEN THE CONTACT IS MISSING, THIS REFUSES — and records the refusal.
|
|
74
|
-
*
|
|
75
|
-
* Refusing is right on AGL-1732's test, "does refusing discard money or work
|
|
76
|
-
* that already happened?", because it does not: the refund is durable on the
|
|
77
|
-
* order (`refundedCents`, the timeline entry, Stripe), and the contact is a
|
|
78
|
-
* derived aggregate that AGL-1753's rebuild reads back out of those same
|
|
79
|
-
* orders. Creating one here would be worse three ways — creation is audience
|
|
80
|
-
* BAND gated, so it hard-bands a Free org (raising the AGL-891 dropped-contacts
|
|
81
|
-
* alert for a record the merchant never had) or meters onto a paid invoice; the
|
|
82
|
-
* contact it created would hold a refund and no purchase, which is the negative
|
|
83
|
-
* lifetime value above with nothing to reconcile it against; and it is the
|
|
84
|
-
* phantom-document shape the `updateExisting` sweep spent yesterday closing.
|
|
85
|
-
*
|
|
86
|
-
* So the write is an `update()`, never `set(…, { merge: true })` — the contact
|
|
87
|
-
* may have been deleted between the query and the write, and a merge-set would
|
|
88
|
-
* resurrect it as a document holding nothing but a refund. Every refusal
|
|
89
|
-
* increments `hosts/{hostId}/counters/contactRefundsUnmatched`, the counter
|
|
90
|
-
* shape `contactsDropped` already uses, so a refund that reached no contact is
|
|
91
|
-
* countable rather than silent.
|
|
92
|
-
*
|
|
93
|
-
* CHARGEBACKS (AGL-1554) are a separate decision and are NOT handled here.
|
|
94
|
-
* This takes `kind` so the handler that eventually answers
|
|
95
|
-
* `charge.dispute.closed` can record a lost dispute through the same writer
|
|
96
|
-
* with the same fields: money reversed is money reversed, whichever door it
|
|
97
|
-
* left by, and only the wording of the timeline entry differs.
|
|
98
|
-
*/ export async function recordContactRefund(options) {
|
|
99
|
-
// Swallow-all, like `upsertHostContact`: the money has already moved and the
|
|
100
|
-
// order already records it, so nothing here may fail the refund.
|
|
101
|
-
try {
|
|
102
|
-
var _snapshot_get, _snapshot_get1;
|
|
103
|
-
const amountCents = Math.round(Number(options.amountCents));
|
|
104
|
-
if (!(amountCents > 0)) return;
|
|
105
|
-
const email = normalizeContactEmail(options.email);
|
|
106
|
-
if (!email) {
|
|
107
|
-
await recordUnmatchedRefund(options.hostId, options.orderId, 'no-email');
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// Contacts are ORG-scoped (AGL-237), not `hosts/{h}/contacts`, and reads
|
|
111
|
-
// narrow to what this host may see (AGL-1039) — the same lookup
|
|
112
|
-
// `upsertHostContact` makes, through the org's address index
|
|
113
|
-
// (AGL-2633), so both writers resolve the same document even when the
|
|
114
|
-
// order's address is one a merge folded into the buyer's record.
|
|
115
|
-
const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts');
|
|
116
|
-
const snapshot = await findContactByEmail(contactsRef, email, {
|
|
117
|
-
hostId: options.hostId
|
|
118
|
-
});
|
|
119
|
-
if (!snapshot) {
|
|
120
|
-
await recordUnmatchedRefund(options.hostId, options.orderId, 'no-contact');
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
const atMs = Date.now();
|
|
124
|
-
const interaction = {
|
|
125
|
-
// `refId` names an order, so the interaction is an order interaction.
|
|
126
|
-
// There is no `'refund'` ContactSource and this deliberately does not
|
|
127
|
-
// add one: `sources` records which capture silo produced the contact,
|
|
128
|
-
// and a refund captures nobody.
|
|
129
|
-
type: 'order',
|
|
130
|
-
atMs,
|
|
131
|
-
refId: options.orderId,
|
|
132
|
-
summary: `$${(amountCents / 100).toFixed(2)} ` + (options.kind === 'chargeback' ? 'charged back' : 'refunded') + (options.closedTheOrder ? ' (full)' : '')
|
|
133
|
-
};
|
|
134
|
-
// Reused for the newest-first ordering and the interactions cap; only the
|
|
135
|
-
// timeline is taken. The `sources` half of the merge is discarded on
|
|
136
|
-
// purpose, per the note above.
|
|
137
|
-
const { interactions } = mergeContactInteraction({
|
|
138
|
-
sources: (_snapshot_get = snapshot.get('sources')) != null ? _snapshot_get : {},
|
|
139
|
-
interactions: (_snapshot_get1 = snapshot.get('interactions')) != null ? _snapshot_get1 : []
|
|
140
|
-
}, {
|
|
141
|
-
source: 'order',
|
|
142
|
-
interaction
|
|
143
|
-
});
|
|
144
|
-
const FieldValue = firebaseAdmin.firestore.FieldValue;
|
|
145
|
-
// Every field is top-level. `update()` replaces a nested map wholesale
|
|
146
|
-
// rather than merging into it, so anything under a map would have to be
|
|
147
|
-
// written as a dotted path; there is nothing here that is.
|
|
148
|
-
//
|
|
149
|
-
// The amounts are increments, so two refunds settling at once both land.
|
|
150
|
-
// `interactions` is a read-modify-write and therefore CAN lose one of two
|
|
151
|
-
// concurrent entries — the same exposure `upsertHostContact` has on the
|
|
152
|
-
// same array, and it costs a timeline line rather than a number.
|
|
153
|
-
const landed = await updateExisting(snapshot.ref, _extends({
|
|
154
|
-
refundedCents: FieldValue.increment(amountCents)
|
|
155
|
-
}, options.closedTheOrder ? {
|
|
156
|
-
refundedOrdersCount: FieldValue.increment(1)
|
|
157
|
-
} : {}, {
|
|
158
|
-
lastRefundAtMs: atMs,
|
|
159
|
-
interactions,
|
|
160
|
-
updatedAt: FieldValue.serverTimestamp()
|
|
161
|
-
}));
|
|
162
|
-
if (!landed) {
|
|
163
|
-
await recordUnmatchedRefund(options.hostId, options.orderId, 'contact-deleted');
|
|
164
|
-
}
|
|
165
|
-
} catch (error) {
|
|
166
|
-
console.error('recordContactRefund failed', error);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* A refund that reached no contact, recorded rather than dropped (AGL-1754).
|
|
171
|
-
*
|
|
172
|
-
* The `set(…, { merge: true })` here IS meant to conjure the document — it is
|
|
173
|
-
* a counter, and the shape is `counters/contactsDropped`'s, so an operator
|
|
174
|
-
* reading a host's counters finds both kinds of missed contact record in one
|
|
175
|
-
* place. The reason is stamped because the three cases want different answers:
|
|
176
|
-
* `no-email` is an order that never identified its buyer, `no-contact` is the
|
|
177
|
-
* AGL-1753 backfill's work, and `contact-deleted` is a contact removed between
|
|
178
|
-
* the sale and the refund, which nothing should recreate.
|
|
179
|
-
*/ async function recordUnmatchedRefund(hostId, orderId, reason) {
|
|
180
|
-
console.warn('refund reached no contact', {
|
|
181
|
-
hostId,
|
|
182
|
-
orderId,
|
|
183
|
-
reason
|
|
184
|
-
});
|
|
185
|
-
const FieldValue = firebaseAdmin.firestore.FieldValue;
|
|
186
|
-
await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('counters').doc('contactRefundsUnmatched').set({
|
|
187
|
-
total: FieldValue.increment(1),
|
|
188
|
-
lastOrderId: orderId,
|
|
189
|
-
lastReason: reason,
|
|
190
|
-
lastAtMs: Date.now()
|
|
191
|
-
}, {
|
|
192
|
-
merge: true
|
|
193
|
-
}).catch(()=>undefined);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
//# sourceMappingURL=contact-refund.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/contact-refund.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\nimport {\n mergeContactInteraction,\n normalizeContactEmail,\n type ContactInteraction,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin'\n// Leaf imports, not the barrel: `refund.spec.ts` and most specs in this\n// library mock `@aglyn/tenant-data-admin` wholesale, and a permissive stub of\n// either function would turn every case below green without a document ever\n// having to exist.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { updateExisting } from '@aglyn/tenant-data-admin/server/update-existing'\n\n/**\n * Money going BACK to a customer, recorded on their contact (AGL-1754).\n *\n * `upsertHostContact` accumulates `ltvCents` and nothing anywhere decremented\n * it, so a customer who bought $500 and returned all of it read identically to\n * one who bought $500 and kept it — and the first surface built on the field\n * (RFM segmentation, a lifetime-value column, a best-customers list) would have\n * ranked a serial returner among the merchant's best customers.\n *\n * WHAT THIS WRITES, AND WHY IT IS NOT A DECREMENT\n *\n * `ltvCents` is left exactly as it is and the reversal is recorded BESIDE it:\n *\n * | field | meaning |\n * | -- | -- |\n * | `ltvCents` | gross money handed over, unchanged (AGL-1748) |\n * | `refundedCents` | gross money handed back |\n * | `ordersCount` | orders placed, unchanged |\n * | `refundedOrdersCount` | of those, how many ended fully reversed |\n * | `lastRefundAtMs` | when money last went back |\n *\n * This is the shape AGL-1747 chose two days earlier for the same question on\n * the orders CSV — `amountUsd` stayed gross under its existing name and\n * `refundedUsd`/`netUsd` were appended — and it is the shape AGL-1748 named\n * when it recorded the gross semantics in `upsert-contact.ts`. Netting in\n * place would make a stored number mean one thing for rows written before this\n * commit and another for rows written after, with nothing on the document to\n * tell the two apart. Two fields cost one number's worth of storage and keep\n * both facts, and a reader that wants the net has `ltvCents - refundedCents`.\n *\n * It also answers \"can it go negative?\" by moving the question off storage.\n * Both figures are monotonic counters of money that actually moved in one\n * direction, so neither can go negative. The DERIVED net can, for exactly one\n * reason: an order placed before AGL-1748 taught POS, drafts, reservations and\n * bookings to pass `purchaseCents` at all, whose sale was never added and whose\n * refund is subtracted here. That is a true statement about the data, and\n * clamping it away at write time would erase the only evidence that a purchase\n * is missing. AGL-1753 is the backfill that reconciles it, by SETTING both\n * fields from the orders — which already carry `refundedCents`, so whatever a\n * reader decides here costs the rebuild nothing. A merchant-facing reader\n * should show gross and refunded, and clamp only the net it ranks on.\n *\n * `ordersCount` is deliberately NOT decremented. A fully refunded order still\n * happened, and a decrement cannot be undone by a reader that would rather\n * count it — while `refundedOrdersCount` beside it lets RFM's frequency term\n * net the two, or not, at the point of display. Same argument as the amount,\n * one field along.\n *\n * WHEN THE CONTACT IS MISSING, THIS REFUSES — and records the refusal.\n *\n * Refusing is right on AGL-1732's test, \"does refusing discard money or work\n * that already happened?\", because it does not: the refund is durable on the\n * order (`refundedCents`, the timeline entry, Stripe), and the contact is a\n * derived aggregate that AGL-1753's rebuild reads back out of those same\n * orders. Creating one here would be worse three ways — creation is audience\n * BAND gated, so it hard-bands a Free org (raising the AGL-891 dropped-contacts\n * alert for a record the merchant never had) or meters onto a paid invoice; the\n * contact it created would hold a refund and no purchase, which is the negative\n * lifetime value above with nothing to reconcile it against; and it is the\n * phantom-document shape the `updateExisting` sweep spent yesterday closing.\n *\n * So the write is an `update()`, never `set(…, { merge: true })` — the contact\n * may have been deleted between the query and the write, and a merge-set would\n * resurrect it as a document holding nothing but a refund. Every refusal\n * increments `hosts/{hostId}/counters/contactRefundsUnmatched`, the counter\n * shape `contactsDropped` already uses, so a refund that reached no contact is\n * countable rather than silent.\n *\n * CHARGEBACKS (AGL-1554) are a separate decision and are NOT handled here.\n * This takes `kind` so the handler that eventually answers\n * `charge.dispute.closed` can record a lost dispute through the same writer\n * with the same fields: money reversed is money reversed, whichever door it\n * left by, and only the wording of the timeline entry differs.\n */\nexport async function recordContactRefund(options: {\n hostId: string\n /** The order the money went back on — the contact timeline's `refId`. */\n orderId: string\n /**\n * The buyer as the ORDER recorded them, raw. Normalized here, because that\n * is the key the contacts collection is stored under and orders are not\n * consistent about it (AGL-1753 item 3). No `name` rides along on purpose: a\n * refund is not a capture point and must not rewrite who the contact is.\n */\n email: unknown\n /**\n * Cents reversed by THIS attempt, never the order total. `refund.ts` caps\n * each attempt against what is left, so several partials on one order sum to\n * at most the order total; this follows that number rather than recomputing\n * it.\n */\n amountCents: number\n /**\n * True only for the write that moved the order INTO `refunded`. Two partials\n * settling concurrently can both observe a fully refunded total, so the\n * caller decides this from the status transition it made, not from the\n * total it read.\n */\n closedTheOrder: boolean\n kind?: 'refund' | 'chargeback'\n}): Promise<void> {\n // Swallow-all, like `upsertHostContact`: the money has already moved and the\n // order already records it, so nothing here may fail the refund.\n try {\n const amountCents = Math.round(Number(options.amountCents))\n if (!(amountCents > 0)) return\n const email = normalizeContactEmail(options.email)\n if (!email) {\n await recordUnmatchedRefund(options.hostId, options.orderId, 'no-email')\n return\n }\n // Contacts are ORG-scoped (AGL-237), not `hosts/{h}/contacts`, and reads\n // narrow to what this host may see (AGL-1039) — the same lookup\n // `upsertHostContact` makes, through the org's address index\n // (AGL-2633), so both writers resolve the same document even when the\n // order's address is one a merge folded into the buyer's record.\n const contactsRef = await orgDataCollectionForHost(\n options.hostId,\n 'contacts',\n )\n const snapshot = await findContactByEmail(contactsRef, email, {\n hostId: options.hostId,\n })\n if (!snapshot) {\n await recordUnmatchedRefund(options.hostId, options.orderId, 'no-contact')\n return\n }\n const atMs = Date.now()\n const interaction: ContactInteraction = {\n // `refId` names an order, so the interaction is an order interaction.\n // There is no `'refund'` ContactSource and this deliberately does not\n // add one: `sources` records which capture silo produced the contact,\n // and a refund captures nobody.\n type: 'order',\n atMs,\n refId: options.orderId,\n summary:\n `$${(amountCents / 100).toFixed(2)} ` +\n (options.kind === 'chargeback' ? 'charged back' : 'refunded') +\n (options.closedTheOrder ? ' (full)' : ''),\n }\n // Reused for the newest-first ordering and the interactions cap; only the\n // timeline is taken. The `sources` half of the merge is discarded on\n // purpose, per the note above.\n const { interactions } = mergeContactInteraction(\n {\n sources: snapshot.get('sources') ?? {},\n interactions: snapshot.get('interactions') ?? [],\n },\n { source: 'order', interaction },\n )\n\n const FieldValue = firebaseAdmin.firestore.FieldValue\n // Every field is top-level. `update()` replaces a nested map wholesale\n // rather than merging into it, so anything under a map would have to be\n // written as a dotted path; there is nothing here that is.\n //\n // The amounts are increments, so two refunds settling at once both land.\n // `interactions` is a read-modify-write and therefore CAN lose one of two\n // concurrent entries — the same exposure `upsertHostContact` has on the\n // same array, and it costs a timeline line rather than a number.\n const landed = await updateExisting(snapshot.ref, {\n refundedCents: FieldValue.increment(amountCents),\n ...(options.closedTheOrder\n ? { refundedOrdersCount: FieldValue.increment(1) }\n : {}),\n lastRefundAtMs: atMs,\n interactions,\n updatedAt: FieldValue.serverTimestamp(),\n })\n if (!landed) {\n await recordUnmatchedRefund(\n options.hostId,\n options.orderId,\n 'contact-deleted',\n )\n }\n } catch (error) {\n console.error('recordContactRefund failed', error)\n }\n}\n\n/**\n * A refund that reached no contact, recorded rather than dropped (AGL-1754).\n *\n * The `set(…, { merge: true })` here IS meant to conjure the document — it is\n * a counter, and the shape is `counters/contactsDropped`'s, so an operator\n * reading a host's counters finds both kinds of missed contact record in one\n * place. The reason is stamped because the three cases want different answers:\n * `no-email` is an order that never identified its buyer, `no-contact` is the\n * AGL-1753 backfill's work, and `contact-deleted` is a contact removed between\n * the sale and the refund, which nothing should recreate.\n */\nasync function recordUnmatchedRefund(\n hostId: string,\n orderId: string,\n reason: 'no-email' | 'no-contact' | 'contact-deleted',\n): Promise<void> {\n console.warn('refund reached no contact', { hostId, orderId, reason })\n const FieldValue = firebaseAdmin.firestore.FieldValue\n await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('counters')\n .doc('contactRefundsUnmatched')\n .set(\n {\n total: FieldValue.increment(1),\n lastOrderId: orderId,\n lastReason: reason,\n lastAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n}\n"],"names":["mergeContactInteraction","normalizeContactEmail","firebaseAdmin","orgDataCollectionForHost","findContactByEmail","updateExisting","recordContactRefund","options","snapshot","amountCents","Math","round","Number","email","recordUnmatchedRefund","hostId","orderId","contactsRef","atMs","Date","now","interaction","type","refId","summary","toFixed","kind","closedTheOrder","interactions","sources","get","source","FieldValue","firestore","landed","ref","refundedCents","increment","refundedOrdersCount","lastRefundAtMs","updatedAt","serverTimestamp","error","console","reason","warn","app","collection","doc","set","total","lastOrderId","lastReason","lastAtMs","merge","catch","undefined"],"mappings":"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"}
|