@aglyn/plugins-commerce 1.0.0-beta.206 → 1.0.0-beta.209
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 +13 -13
- package/src/lib/components/console/store-settings-card.component.js +11 -1
- package/src/lib/components/console/store-settings-card.component.js.map +1 -1
- package/src/lib/constants/operator-alerts.d.ts +22 -0
- package/src/lib/constants/operator-alerts.js +85 -0
- package/src/lib/constants/operator-alerts.js.map +1 -0
- package/src/lib/declarations.server.d.ts +24 -0
- package/src/lib/declarations.server.js +33 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/server/billing-webhook.js +128 -25
- package/src/lib/server/billing-webhook.js.map +1 -1
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import * as Aglyn from "@aglyn/aglyn/server";
|
|
18
|
-
import { findUserByUidAcrossPools, firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, notifyRiskEvent,
|
|
18
|
+
import { findUserByUidAcrossPools, firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, notifyRiskEvent, raiseOperatorAlert, renderHostEmailWithTokens, clearConnectPayoutFailure, recordConnectPayoutFailure, syncConnectAccountStatus, updateExisting, hostSendingIdentity } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { captureHostContact } from "@aglyn/tenant-runtime";
|
|
20
|
+
import { formatOperatorAlertAmount } from "@aglyn/aglyn/app-utils/operator-alerts";
|
|
21
|
+
import { COMMERCE_CHARGEBACK_UNROUTABLE, COMMERCE_FEE_REPRICE_REFUSED, COMMERCE_SELLER_SHARE_NOT_REVERSED, COMMERCE_TAX_NOT_REVERSED } from "../constants/operator-alerts.js";
|
|
20
22
|
import { createHmac } from "crypto";
|
|
21
23
|
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
22
24
|
import * as CommerceModel from "../model/index.js";
|
|
@@ -274,21 +276,37 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
274
276
|
*
|
|
275
277
|
* Staff, not the merchant: on the missing-index path there is no merchant to
|
|
276
278
|
* tell — the whole point is that the order could not be found — and a missing
|
|
277
|
-
* platform index is a platform fault affecting every host at once.
|
|
278
|
-
* is the same channel the analytics and abuse routes use for exactly this
|
|
279
|
-
* class, and it never throws.
|
|
279
|
+
* platform index is a platform fault affecting every host at once.
|
|
280
280
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
281
|
+
* As this plugin's own operator alert (AGL-3377), which reaches the operator's
|
|
282
|
+
* inbox and webhook as well as the console. A missing index is one alert for
|
|
283
|
+
* every chargeback it drops, not one per chargeback.
|
|
284
284
|
*/ async function reportUnresolvedDispute(reason, paymentIntentId, dispute) {
|
|
285
285
|
var _dispute_amount;
|
|
286
|
-
const amount =
|
|
287
|
-
await
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
286
|
+
const amount = formatOperatorAlertAmount((_dispute_amount = dispute.amount) != null ? _dispute_amount : 0);
|
|
287
|
+
await raiseOperatorAlert(COMMERCE_CHARGEBACK_UNROUTABLE, {
|
|
288
|
+
dedupeKey: reason === 'missing-index' ? 'missing-index' : paymentIntentId,
|
|
289
|
+
url: '/admin',
|
|
290
|
+
context: {
|
|
291
|
+
amount,
|
|
292
|
+
detail: reason === 'missing-index' ? `A ${amount} chargeback (${paymentIntentId}) could not be looked ` + 'up: the collection-group index on orders.paymentIntentId is not ' + 'deployed, so EVERY commerce chargeback is currently being ignored ' + '— no flag, no seller-share reversal, no merchant notification. ' + 'Deploy cloud/firebase-firestore.indexes.json.' : `A ${amount} chargeback (${paymentIntentId}) matched more than one ` + 'order, so none was reversed. The dispute is durable in Stripe and ' + 'needs reconciling by hand.'
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Tells the operator that a subscription cycle's sales tax stayed with the
|
|
298
|
+
* merchant (AGL-3377): the platform files and remits it, so every one of
|
|
299
|
+
* these is tax it may owe and does not hold. Once per invoice and outcome, so
|
|
300
|
+
* Stripe's redeliveries of the same invoice say it once.
|
|
301
|
+
*/ async function reportTaxNotReversed(input) {
|
|
302
|
+
await raiseOperatorAlert(COMMERCE_TAX_NOT_REVERSED, {
|
|
303
|
+
dedupeKey: `${input.invoiceId}:${input.status}`,
|
|
304
|
+
context: {
|
|
305
|
+
invoiceId: input.invoiceId,
|
|
306
|
+
hostId: input.hostId,
|
|
307
|
+
amount: input.taxCents === undefined ? 'An unknown amount' : formatOperatorAlertAmount(input.taxCents),
|
|
308
|
+
reason: input.reason
|
|
309
|
+
}
|
|
292
310
|
});
|
|
293
311
|
}
|
|
294
312
|
/** The dispute record this event describes, before any outcome is known. */ function disputeFromEvent(dispute) {
|
|
@@ -713,10 +731,21 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
713
731
|
}).toString()
|
|
714
732
|
}).catch(()=>null);
|
|
715
733
|
if (!response || !response.ok) {
|
|
734
|
+
var _ref, _ref1;
|
|
735
|
+
var _subscriptionRef_parent_parent, _refusal_error;
|
|
716
736
|
if (!response || isTransientStripeStatus(response.status)) {
|
|
717
737
|
throw new Error(`Stripe refused to re-price subscription ${subscriptionId} ` + `(${response ? response.status : 'network'})`);
|
|
718
738
|
}
|
|
719
|
-
|
|
739
|
+
const refusal = await response.json().catch(()=>null);
|
|
740
|
+
console.error(`Stripe refused the fee re-price for ${subscriptionId} (AGL-2289)`, refusal);
|
|
741
|
+
await raiseOperatorAlert(COMMERCE_FEE_REPRICE_REFUSED, {
|
|
742
|
+
dedupeKey: `${subscriptionId}:${desiredFeePct}`,
|
|
743
|
+
context: {
|
|
744
|
+
subject: `subscription ${subscriptionId}`,
|
|
745
|
+
hostId: (_ref = (_subscriptionRef_parent_parent = subscriptionRef.parent.parent) == null ? void 0 : _subscriptionRef_parent_parent.id) != null ? _ref : '',
|
|
746
|
+
reason: `Stripe refused to set the fee to ${desiredFeePct}% (${(_ref1 = refusal == null ? void 0 : (_refusal_error = refusal.error) == null ? void 0 : _refusal_error.message) != null ? _ref1 : `HTTP ${response.status}`})`
|
|
747
|
+
}
|
|
748
|
+
});
|
|
720
749
|
return;
|
|
721
750
|
}
|
|
722
751
|
await subscriptionRef.set({
|
|
@@ -725,6 +754,20 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
725
754
|
merge: true
|
|
726
755
|
}).catch(()=>undefined);
|
|
727
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Tells the operator a subscription cycle kept a fee charged on the wrong
|
|
759
|
+
* base (AGL-3377): the merchant paid more than the plan sets. Once per
|
|
760
|
+
* invoice.
|
|
761
|
+
*/ async function reportFeeNotCorrected(hostId, invoiceId, reason) {
|
|
762
|
+
await raiseOperatorAlert(COMMERCE_FEE_REPRICE_REFUSED, {
|
|
763
|
+
dedupeKey: invoiceId,
|
|
764
|
+
context: {
|
|
765
|
+
subject: `invoice ${invoiceId}`,
|
|
766
|
+
hostId,
|
|
767
|
+
reason
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
}
|
|
728
771
|
/**
|
|
729
772
|
* The charge a paid invoice settled against, on whichever spelling this
|
|
730
773
|
* endpoint's API version uses (AGL-2317).
|
|
@@ -848,6 +891,7 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
848
891
|
hostId,
|
|
849
892
|
invoiceId
|
|
850
893
|
});
|
|
894
|
+
await reportFeeNotCorrected(hostId, invoiceId, 'STRIPE_SECRET_KEY is not set on this deployment');
|
|
851
895
|
return chargedCents;
|
|
852
896
|
}
|
|
853
897
|
const chargeId = await resolveInvoiceChargeId(invoice, stripeKey);
|
|
@@ -856,6 +900,7 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
856
900
|
hostId,
|
|
857
901
|
invoiceId
|
|
858
902
|
});
|
|
903
|
+
await reportFeeNotCorrected(hostId, invoiceId, 'the invoice names no charge');
|
|
859
904
|
return chargedCents;
|
|
860
905
|
}
|
|
861
906
|
const fees = await stripeGet(`https://api.stripe.com/v1/application_fees?limit=1&charge=${encodeURIComponent(chargeId)}`, stripeKey);
|
|
@@ -870,6 +915,7 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
870
915
|
chargeId,
|
|
871
916
|
status: fees.status
|
|
872
917
|
});
|
|
918
|
+
await reportFeeNotCorrected(hostId, invoiceId, `no application fee was found on charge ${chargeId}`);
|
|
873
919
|
return chargedCents;
|
|
874
920
|
}
|
|
875
921
|
const feeAmount = Math.max(0, Math.round(Number((_fee_amount = fee.amount) != null ? _fee_amount : 0)));
|
|
@@ -890,6 +936,8 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
890
936
|
}).toString()
|
|
891
937
|
}).catch(()=>null);
|
|
892
938
|
if (!response || !response.ok) {
|
|
939
|
+
var _ref1;
|
|
940
|
+
var _refusal_error;
|
|
893
941
|
if (!response || isTransientStripeStatus(response.status)) {
|
|
894
942
|
// Let Stripe redeliver — the ledger write is keyed on the invoice id, so
|
|
895
943
|
// a retry re-runs this and nothing else (the AGL-1743 discipline).
|
|
@@ -897,7 +945,9 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
897
945
|
}
|
|
898
946
|
// Definitive: no redelivery fixes it, and throwing would have Stripe retry
|
|
899
947
|
// the whole invoice forever. Record what was actually taken.
|
|
900
|
-
|
|
948
|
+
const refusal = await response.json().catch(()=>null);
|
|
949
|
+
console.error(`Stripe refused the items-only fee correction for ${invoiceId} (AGL-2317)`, refusal);
|
|
950
|
+
await reportFeeNotCorrected(hostId, invoiceId, `Stripe refused to refund ${formatOperatorAlertAmount(refundCents)} of the fee (${(_ref1 = refusal == null ? void 0 : (_refusal_error = refusal.error) == null ? void 0 : _refusal_error.message) != null ? _ref1 : `HTTP ${response.status}`})`);
|
|
901
951
|
return chargedCents;
|
|
902
952
|
}
|
|
903
953
|
return desiredCents;
|
|
@@ -1102,11 +1152,12 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1102
1152
|
status: 'unreadable',
|
|
1103
1153
|
reason: decision.reason
|
|
1104
1154
|
});
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1155
|
+
await reportTaxNotReversed({
|
|
1156
|
+
hostId,
|
|
1157
|
+
invoiceId,
|
|
1158
|
+
status: 'unreadable',
|
|
1159
|
+
reason: `the invoice enables automatic tax but states no readable tax field (${decision.reason})`
|
|
1160
|
+
});
|
|
1110
1161
|
return;
|
|
1111
1162
|
}
|
|
1112
1163
|
const taxCents = decision.taxCents;
|
|
@@ -1116,6 +1167,13 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1116
1167
|
hostId,
|
|
1117
1168
|
invoiceId
|
|
1118
1169
|
});
|
|
1170
|
+
await reportTaxNotReversed({
|
|
1171
|
+
hostId,
|
|
1172
|
+
invoiceId,
|
|
1173
|
+
taxCents,
|
|
1174
|
+
status: 'no-key',
|
|
1175
|
+
reason: 'STRIPE_SECRET_KEY is not set on this deployment'
|
|
1176
|
+
});
|
|
1119
1177
|
return;
|
|
1120
1178
|
}
|
|
1121
1179
|
// GUARD 1. Every ordinary redelivery stops here, before any Stripe call, and
|
|
@@ -1168,6 +1226,13 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1168
1226
|
status: 'charge-unreadable',
|
|
1169
1227
|
taxCents
|
|
1170
1228
|
});
|
|
1229
|
+
await reportTaxNotReversed({
|
|
1230
|
+
hostId,
|
|
1231
|
+
invoiceId,
|
|
1232
|
+
taxCents,
|
|
1233
|
+
status: 'charge-unreadable',
|
|
1234
|
+
reason: `Stripe refused to read charge ${chargeId}`
|
|
1235
|
+
});
|
|
1171
1236
|
return;
|
|
1172
1237
|
}
|
|
1173
1238
|
const transferId = String((_ref = (_charge_body = charge.body) == null ? void 0 : _charge_body.transfer) != null ? _ref : '');
|
|
@@ -1197,6 +1262,13 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1197
1262
|
status: 'transfer-unreadable',
|
|
1198
1263
|
taxCents
|
|
1199
1264
|
});
|
|
1265
|
+
await reportTaxNotReversed({
|
|
1266
|
+
hostId,
|
|
1267
|
+
invoiceId,
|
|
1268
|
+
taxCents,
|
|
1269
|
+
status: 'transfer-unreadable',
|
|
1270
|
+
reason: `Stripe refused to read transfer ${transferId}`
|
|
1271
|
+
});
|
|
1200
1272
|
return;
|
|
1201
1273
|
}
|
|
1202
1274
|
// GUARD 2 — the crash window, and LOAD-BEARING now that the idempotency key
|
|
@@ -1258,6 +1330,13 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1258
1330
|
transferId,
|
|
1259
1331
|
owedCents: taxCents
|
|
1260
1332
|
});
|
|
1333
|
+
await reportTaxNotReversed({
|
|
1334
|
+
hostId,
|
|
1335
|
+
invoiceId,
|
|
1336
|
+
taxCents,
|
|
1337
|
+
status: 'nothing-left',
|
|
1338
|
+
reason: `transfer ${transferId} had nothing left to reverse, so the tax must be reconciled by hand`
|
|
1339
|
+
});
|
|
1261
1340
|
return;
|
|
1262
1341
|
}
|
|
1263
1342
|
const response = await fetch(`https://api.stripe.com/v1/transfers/${encodeURIComponent(transferId)}/reversals`, {
|
|
@@ -1313,11 +1392,13 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1313
1392
|
status: 'refused'
|
|
1314
1393
|
}, failureFields));
|
|
1315
1394
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1395
|
+
await reportTaxNotReversed({
|
|
1396
|
+
hostId,
|
|
1397
|
+
invoiceId,
|
|
1398
|
+
taxCents,
|
|
1399
|
+
status: insufficient ? 'insufficient' : 'refused',
|
|
1400
|
+
reason: insufficient ? 'the connected account had insufficient balance; it will retry on the next delivery' : `Stripe refused the reversal: ${String((_ref9 = body == null ? void 0 : (_body_error2 = body.error) == null ? void 0 : _body_error2.message) != null ? _ref9 : 'no reason given')}`
|
|
1401
|
+
});
|
|
1321
1402
|
return;
|
|
1322
1403
|
}
|
|
1323
1404
|
const reversedCents = Math.max(0, Math.round(Number((_ref4 = body == null ? void 0 : body.amount) != null ? _ref4 : reverseCents)));
|
|
@@ -1449,6 +1530,7 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1449
1530
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
1450
1531
|
if (!stripeKey) {
|
|
1451
1532
|
console.error('Transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-1794)');
|
|
1533
|
+
await reportSellerShareNotReversed(orderRef, disputeId, principalCents, 'STRIPE_SECRET_KEY is not set on this deployment');
|
|
1452
1534
|
return 0;
|
|
1453
1535
|
}
|
|
1454
1536
|
/**
|
|
@@ -1505,6 +1587,10 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1505
1587
|
reason: outcome.reason
|
|
1506
1588
|
});
|
|
1507
1589
|
await settle(0, null, SELLER_SHARE_NOT_REVERSED_NOTE[outcome.reason]);
|
|
1590
|
+
// Already reversed on the transfer is money back, not money lost.
|
|
1591
|
+
if (outcome.reason !== 'transfer-fully-reversed') {
|
|
1592
|
+
await reportSellerShareNotReversed(orderRef, disputeId, principalCents, SELLER_SHARE_NOT_REVERSED_NOTE[outcome.reason]);
|
|
1593
|
+
}
|
|
1508
1594
|
return 0;
|
|
1509
1595
|
}
|
|
1510
1596
|
// Announced on adoption too: the delivery that created an adopted reversal
|
|
@@ -1513,6 +1599,23 @@ async function findOrderForDispute(paymentIntentId) {
|
|
|
1513
1599
|
const wrote = await settle(outcome.cents, outcome.reversalId, `$${(outcome.cents / 100).toFixed(2)} seller share reversed for lost dispute`);
|
|
1514
1600
|
return wrote ? outcome.cents : 0;
|
|
1515
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Tells the operator the platform is out a lost dispute's principal because
|
|
1604
|
+
* the merchant's share could not be pulled back (AGL-3377). Once per dispute.
|
|
1605
|
+
*/ async function reportSellerShareNotReversed(orderRef, disputeId, principalCents, reason) {
|
|
1606
|
+
var _ref;
|
|
1607
|
+
var _orderRef_parent_parent;
|
|
1608
|
+
await raiseOperatorAlert(COMMERCE_SELLER_SHARE_NOT_REVERSED, {
|
|
1609
|
+
dedupeKey: disputeId,
|
|
1610
|
+
context: {
|
|
1611
|
+
disputeId,
|
|
1612
|
+
orderId: orderRef.id,
|
|
1613
|
+
hostId: (_ref = (_orderRef_parent_parent = orderRef.parent.parent) == null ? void 0 : _orderRef_parent_parent.id) != null ? _ref : '',
|
|
1614
|
+
amount: formatOperatorAlertAmount(principalCents),
|
|
1615
|
+
reason
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1516
1619
|
/** The order timeline's words for each final reason nothing was pulled back. */ const SELLER_SHARE_NOT_REVERSED_NOTE = {
|
|
1517
1620
|
'no-charge-on-cause': 'Seller share not reversed — no charge on the dispute',
|
|
1518
1621
|
'charge-read-refused': 'Seller share not reversed — charge not found at Stripe',
|