@aglyn/plugins-marketplace 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
- package/src/lib/components/artifact-update-dialog.component.js +244 -0
- package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
- package/src/lib/components/host-plugins-card.component.d.ts +23 -0
- package/src/lib/components/host-plugins-card.component.js +566 -0
- package/src/lib/components/host-plugins-card.component.js.map +1 -0
- package/src/lib/components/listing-content.component.d.ts +57 -0
- package/src/lib/components/listing-content.component.js +1880 -0
- package/src/lib/components/listing-content.component.js.map +1 -0
- package/src/lib/components/listing-image.component.d.ts +46 -0
- package/src/lib/components/listing-image.component.js +38 -0
- package/src/lib/components/listing-image.component.js.map +1 -0
- package/src/lib/components/listing-reviews.component.d.ts +22 -0
- package/src/lib/components/listing-reviews.component.js +419 -0
- package/src/lib/components/listing-reviews.component.js.map +1 -0
- package/src/lib/components/marketplace-browse.component.d.ts +38 -0
- package/src/lib/components/marketplace-browse.component.js +802 -0
- package/src/lib/components/marketplace-browse.component.js.map +1 -0
- package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
- package/src/lib/components/plugin-site-set-panel.component.js +137 -0
- package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
- package/src/lib/components/plugin-site-set.component.d.ts +43 -0
- package/src/lib/components/plugin-site-set.component.js +425 -0
- package/src/lib/components/plugin-site-set.component.js.map +1 -0
- package/src/lib/components/rating-input.component.d.ts +3 -0
- package/src/lib/components/rating-input.component.js +47 -0
- package/src/lib/components/rating-input.component.js.map +1 -0
- package/src/lib/components/report-target.component.d.ts +36 -0
- package/src/lib/components/report-target.component.js +181 -0
- package/src/lib/components/report-target.component.js.map +1 -0
- package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
- package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
- package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/hooks/use-artifact-update.d.ts +41 -0
- package/src/lib/hooks/use-artifact-update.js +169 -0
- package/src/lib/hooks/use-artifact-update.js.map +1 -0
- package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
- package/src/lib/hooks/use-marketplace-actions.js +523 -0
- package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
- package/src/lib/model/artifact-merge.d.ts +154 -0
- package/src/lib/model/artifact-merge.js +228 -0
- package/src/lib/model/artifact-merge.js.map +1 -0
- package/src/lib/model/index.d.ts +43 -0
- package/src/lib/model/index.js +41 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketplace-props.d.ts +57 -0
- package/src/lib/model/marketplace-props.js +441 -0
- package/src/lib/model/marketplace-props.js.map +1 -0
- package/src/lib/model/marketplace.d.ts +833 -0
- package/src/lib/model/marketplace.js +929 -0
- package/src/lib/model/marketplace.js.map +1 -0
- package/src/lib/model/rating-field.d.ts +24 -0
- package/src/lib/model/rating-field.js +33 -0
- package/src/lib/model/rating-field.js.map +1 -0
- package/src/lib/plugin.d.ts +33 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +25 -0
- package/src/lib/server/billing-webhook.js +1062 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/checkout.d.ts +76 -0
- package/src/lib/server/checkout.js +477 -0
- package/src/lib/server/checkout.js.map +1 -0
- package/src/lib/server/connect.d.ts +24 -0
- package/src/lib/server/connect.js +205 -0
- package/src/lib/server/connect.js.map +1 -0
- package/src/lib/server/install-dataset-schema.d.ts +32 -0
- package/src/lib/server/install-dataset-schema.js +255 -0
- package/src/lib/server/install-dataset-schema.js.map +1 -0
- package/src/lib/server/install-email-starter.d.ts +65 -0
- package/src/lib/server/install-email-starter.js +279 -0
- package/src/lib/server/install-email-starter.js.map +1 -0
- package/src/lib/server/install-email-template.d.ts +34 -0
- package/src/lib/server/install-email-template.js +252 -0
- package/src/lib/server/install-email-template.js.map +1 -0
- package/src/lib/server/install-layout.d.ts +29 -0
- package/src/lib/server/install-layout.js +323 -0
- package/src/lib/server/install-layout.js.map +1 -0
- package/src/lib/server/install-pin-counts.d.ts +173 -0
- package/src/lib/server/install-pin-counts.js +248 -0
- package/src/lib/server/install-pin-counts.js.map +1 -0
- package/src/lib/server/install-plugin.d.ts +18 -0
- package/src/lib/server/install-plugin.js +405 -0
- package/src/lib/server/install-plugin.js.map +1 -0
- package/src/lib/server/install-template.d.ts +37 -0
- package/src/lib/server/install-template.js +348 -0
- package/src/lib/server/install-template.js.map +1 -0
- package/src/lib/server/install-theme.d.ts +42 -0
- package/src/lib/server/install-theme.js +313 -0
- package/src/lib/server/install-theme.js.map +1 -0
- package/src/lib/server/install.d.ts +27 -0
- package/src/lib/server/install.js +276 -0
- package/src/lib/server/install.js.map +1 -0
- package/src/lib/server/listing-versions.d.ts +26 -0
- package/src/lib/server/listing-versions.js +378 -0
- package/src/lib/server/listing-versions.js.map +1 -0
- package/src/lib/server/preview-image.d.ts +37 -0
- package/src/lib/server/preview-image.js +216 -0
- package/src/lib/server/preview-image.js.map +1 -0
- package/src/lib/server/provenance.d.ts +100 -0
- package/src/lib/server/provenance.js +118 -0
- package/src/lib/server/provenance.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.d.ts +31 -0
- package/src/lib/server/publish-dataset-schema.js +202 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -0
- package/src/lib/server/publish-email-starter.d.ts +44 -0
- package/src/lib/server/publish-email-starter.js +231 -0
- package/src/lib/server/publish-email-starter.js.map +1 -0
- package/src/lib/server/publish-email-template.d.ts +33 -0
- package/src/lib/server/publish-email-template.js +211 -0
- package/src/lib/server/publish-email-template.js.map +1 -0
- package/src/lib/server/publish-layout.d.ts +33 -0
- package/src/lib/server/publish-layout.js +243 -0
- package/src/lib/server/publish-layout.js.map +1 -0
- package/src/lib/server/publish-plugin.d.ts +18 -0
- package/src/lib/server/publish-plugin.js +485 -0
- package/src/lib/server/publish-plugin.js.map +1 -0
- package/src/lib/server/publish-preconditions.d.ts +86 -0
- package/src/lib/server/publish-preconditions.js +160 -0
- package/src/lib/server/publish-preconditions.js.map +1 -0
- package/src/lib/server/publish-template.d.ts +27 -0
- package/src/lib/server/publish-template.js +215 -0
- package/src/lib/server/publish-template.js.map +1 -0
- package/src/lib/server/publish-theme.d.ts +38 -0
- package/src/lib/server/publish-theme.js +186 -0
- package/src/lib/server/publish-theme.js.map +1 -0
- package/src/lib/server/publish.d.ts +26 -0
- package/src/lib/server/publish.js +204 -0
- package/src/lib/server/publish.js.map +1 -0
- package/src/lib/server/publisher-profile-save.d.ts +30 -0
- package/src/lib/server/publisher-profile-save.js +226 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -0
- package/src/lib/server/publisher-profile.d.ts +86 -0
- package/src/lib/server/publisher-profile.js +116 -0
- package/src/lib/server/publisher-profile.js.map +1 -0
- package/src/lib/server/purchase-entitlement.d.ts +83 -0
- package/src/lib/server/purchase-entitlement.js +118 -0
- package/src/lib/server/purchase-entitlement.js.map +1 -0
- package/src/lib/server/report.d.ts +32 -0
- package/src/lib/server/report.js +101 -0
- package/src/lib/server/report.js.map +1 -0
- package/src/lib/server/reviews.d.ts +41 -0
- package/src/lib/server/reviews.js +240 -0
- package/src/lib/server/reviews.js.map +1 -0
- package/src/lib/server/update-artifact.d.ts +19 -0
- package/src/lib/server/update-artifact.js +569 -0
- package/src/lib/server/update-artifact.js.map +1 -0
- package/src/lib/server/verification-request.d.ts +31 -0
- package/src/lib/server/verification-request.js +173 -0
- package/src/lib/server/verification-request.js.map +1 -0
- package/src/lib/server/version-stats.d.ts +202 -0
- package/src/lib/server/version-stats.js +305 -0
- package/src/lib/server/version-stats.js.map +1 -0
- package/src/lib/server.d.ts +30 -0
- package/src/lib/server.js +95 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,1062 @@
|
|
|
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
|
+
*/ // `after()`, never a bare `void promise` (AGL-2327). This handler runs inside
|
|
18
|
+
// the console's `/api/billing/webhook` invocation, which AGL-1133 measured on
|
|
19
|
+
// production is frozen the moment the response is sent — so fire-and-forget
|
|
20
|
+
// work scheduled here simply does not run. The console's own route was
|
|
21
|
+
// migrated for that reason (AGL-2346) and this one, in the same invocation,
|
|
22
|
+
// was not: marketplace revenue and refunds were reporting to nothing, which is
|
|
23
|
+
// the surviving half of "four server events ship to nothing" now that the
|
|
24
|
+
// Measurement Protocol credentials have landed (2026-08-17).
|
|
25
|
+
import { after } from "next/server";
|
|
26
|
+
import { firebaseAdmin, sendGa4Purchase, sendGa4Refund, clearConnectPayoutFailure, recordConnectPayoutFailure, syncConnectAccountStatus } from "@aglyn/tenant-data-admin";
|
|
27
|
+
/** Stripe failures a redelivery can actually fix. */ function isTransientStripeStatus(status) {
|
|
28
|
+
return status === 429 || status >= 500;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* THE BUYER'S TAX JURISDICTION, from the session Stripe computed the tax on.
|
|
32
|
+
*
|
|
33
|
+
* Aglyn is a marketplace facilitator, so the tax on a facilitated sale is
|
|
34
|
+
* Aglyn's to collect and remit — and a return reports it BY STATE. Checkout
|
|
35
|
+
* already collects everything needed for that (`billing_address_collection:
|
|
36
|
+
* 'required'`, `automatic_tax[enabled]`), and Stripe states the address it
|
|
37
|
+
* computed from back on `customer_details.address`. Nothing read it, so every
|
|
38
|
+
* facilitated sale reached `tx-return.ts` with no jurisdiction and the whole
|
|
39
|
+
* of the marketplace tax could be stated only as a platform total attributable
|
|
40
|
+
* to no state.
|
|
41
|
+
*
|
|
42
|
+
* COUNTRY AND STATE ONLY, and deliberately narrower than the storefront twin.
|
|
43
|
+
* `StorefrontTaxRow` keeps `city` and `postalCode` beside them; the return
|
|
44
|
+
* reads neither, on either collection — it keys every jurisdiction bucket
|
|
45
|
+
* `COUNTRY-STATE`. A purchase document already names `buyerUid`, so anything
|
|
46
|
+
* stored here is personal data by association, and a street-grained address a
|
|
47
|
+
* filing cannot use is data held for no purpose. The two fields kept are the
|
|
48
|
+
* two the return reads.
|
|
49
|
+
*
|
|
50
|
+
* Null when the session states no country. An unstated jurisdiction is COUNTED
|
|
51
|
+
* by the return (`rowsMissingJurisdiction`) and never inferred: a jurisdiction
|
|
52
|
+
* reconstructed after the fact is a guess handed to a tax authority as a fact.
|
|
53
|
+
*/ function buyerTaxJurisdiction(object) {
|
|
54
|
+
var _object_customer_details;
|
|
55
|
+
const address = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.address;
|
|
56
|
+
const country = typeof (address == null ? void 0 : address.country) === 'string' ? address.country.trim() : '';
|
|
57
|
+
if (!country) return null;
|
|
58
|
+
const state = typeof (address == null ? void 0 : address.state) === 'string' ? address.state.trim() : '';
|
|
59
|
+
return {
|
|
60
|
+
country,
|
|
61
|
+
state: state || null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** One authorized GET against the Stripe API, body parsed either way. */ async function stripeGet(url, stripeKey) {
|
|
65
|
+
const response = await fetch(url, {
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${stripeKey}`
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const body = await response.json().catch(()=>null);
|
|
71
|
+
return {
|
|
72
|
+
ok: response.ok,
|
|
73
|
+
status: response.status,
|
|
74
|
+
body
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
78
|
+
var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9;
|
|
79
|
+
var _charge_body, _charge_body1, _transfer_body_reversals, _transfer_body, _transfer_body1, _transfer_body2;
|
|
80
|
+
const causeId = String((_ref = cause == null ? void 0 : cause.id) != null ? _ref : '');
|
|
81
|
+
if (!causeId) return;
|
|
82
|
+
// Kept as `disputeId` on the wire for `kind: 'dispute'` so reversals
|
|
83
|
+
// stamped before AGL-1995 are still found and ADOPTED by the crash-window
|
|
84
|
+
// backstop below. A refund uses its own key for the same reason.
|
|
85
|
+
const metadataKey = cause.kind === 'dispute' ? 'disputeId' : 'refundId';
|
|
86
|
+
const snapshot = await purchaseRef.get();
|
|
87
|
+
if (!snapshot.exists) return;
|
|
88
|
+
// The settle marker — see the doc comment. 0 counts.
|
|
89
|
+
if (snapshot.get('reversedTransferCents') != null) return;
|
|
90
|
+
const causeAmountCents = Math.round(Number((_ref1 = cause == null ? void 0 : cause.amountCents) != null ? _ref1 : 0));
|
|
91
|
+
if (!(causeAmountCents > 0)) return;
|
|
92
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
93
|
+
if (!stripeKey) {
|
|
94
|
+
console.error('Marketplace transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-1554)');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Settles the step exactly once, whatever it found.
|
|
99
|
+
*
|
|
100
|
+
* A settle of ZERO is not the same event as a settle of the full share, and
|
|
101
|
+
* until AGL-2140 the document could not tell them apart (AGL-2140). Six
|
|
102
|
+
* paths below abandon the reversal for a DEFINITIVE reason — no charge id,
|
|
103
|
+
* Stripe refusing the charge read, no transfer on the charge, Stripe
|
|
104
|
+
* refusing the transfer read, nothing left to reverse, Stripe refusing the
|
|
105
|
+
* reversal itself — and each wrote `reversedTransferCents: 0`, which the
|
|
106
|
+
* short-circuit at the top of this function then honours forever. The only
|
|
107
|
+
* other trace was a `console.error`.
|
|
108
|
+
*
|
|
109
|
+
* A Stripe 4xx is one of those definitive reasons: `balance_insufficient` on
|
|
110
|
+
* a connected account is a **400**, not a 5xx, so it does not throw and does
|
|
111
|
+
* not redeliver. The publisher's share was therefore forfeited permanently,
|
|
112
|
+
* silently, and with nothing queryable to find it by afterwards.
|
|
113
|
+
*
|
|
114
|
+
* Settling stays — re-running these paths would eventually double-debit a
|
|
115
|
+
* seller, which is worse. What changes is that an abandoned reversal now
|
|
116
|
+
* says so ON THE DOCUMENT: `reversalFailedAt`, a machine-readable
|
|
117
|
+
* `reversalFailedReason`, and `reversalOwedCents` when the amount is known.
|
|
118
|
+
* `where('reversalFailedAt', '!=', null)` is then the recovery queue.
|
|
119
|
+
*/ const settle = async (reversedTransferCents, transferReversalId, /** Why nothing (or less than the cause) was reversed. Omit on success. */ failure)=>{
|
|
120
|
+
const fresh = await purchaseRef.get();
|
|
121
|
+
if (!fresh.exists || fresh.get('reversedTransferCents') != null) return;
|
|
122
|
+
if (failure) {
|
|
123
|
+
var _failure_owedCents;
|
|
124
|
+
console.error('Marketplace seller share NOT reversed — recorded for recovery', {
|
|
125
|
+
purchaseId: purchaseRef.id,
|
|
126
|
+
kind: cause.kind,
|
|
127
|
+
causeId,
|
|
128
|
+
reason: failure.reason,
|
|
129
|
+
owedCents: (_failure_owedCents = failure.owedCents) != null ? _failure_owedCents : null
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
await purchaseRef.set(_extends({
|
|
133
|
+
reversedTransferCents
|
|
134
|
+
}, transferReversalId ? {
|
|
135
|
+
transferReversalId
|
|
136
|
+
} : {}, failure ? _extends({
|
|
137
|
+
reversalFailedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
|
|
138
|
+
reversalFailedReason: failure.reason,
|
|
139
|
+
reversalFailedCause: cause.kind
|
|
140
|
+
}, failure.owedCents != null ? {
|
|
141
|
+
reversalOwedCents: failure.owedCents
|
|
142
|
+
} : {}) : {}), {
|
|
143
|
+
merge: true
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
const chargeId = String((_ref2 = cause == null ? void 0 : cause.chargeId) != null ? _ref2 : '');
|
|
147
|
+
if (!chargeId) {
|
|
148
|
+
await settle(0, null, {
|
|
149
|
+
reason: 'no-charge-on-cause'
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const charge = await stripeGet(`https://api.stripe.com/v1/charges/${chargeId}`, stripeKey);
|
|
154
|
+
if (!charge.ok) {
|
|
155
|
+
var _charge_body2;
|
|
156
|
+
if (isTransientStripeStatus(charge.status)) {
|
|
157
|
+
throw new Error(`Stripe charge read failed (${charge.status}) for marketplace ${cause.kind} ${causeId}`);
|
|
158
|
+
}
|
|
159
|
+
console.error('Stripe refused the charge read', (_charge_body2 = charge.body) == null ? void 0 : _charge_body2.error);
|
|
160
|
+
await settle(0, null, {
|
|
161
|
+
reason: 'charge-read-refused'
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const transferId = String((_ref3 = (_charge_body = charge.body) == null ? void 0 : _charge_body.transfer) != null ? _ref3 : '');
|
|
166
|
+
const chargeAmountCents = Math.round(Number((_ref4 = (_charge_body1 = charge.body) == null ? void 0 : _charge_body1.amount) != null ? _ref4 : 0));
|
|
167
|
+
if (!transferId || !(chargeAmountCents > 0)) {
|
|
168
|
+
await settle(0, null, {
|
|
169
|
+
reason: 'no-transfer-on-charge'
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${transferId}`, stripeKey);
|
|
174
|
+
if (!transfer.ok) {
|
|
175
|
+
var _transfer_body3;
|
|
176
|
+
if (isTransientStripeStatus(transfer.status)) {
|
|
177
|
+
throw new Error(`Stripe transfer read failed (${transfer.status}) for marketplace ${cause.kind} ${causeId}`);
|
|
178
|
+
}
|
|
179
|
+
console.error('Stripe refused the transfer read', (_transfer_body3 = transfer.body) == null ? void 0 : _transfer_body3.error);
|
|
180
|
+
await settle(0, null, {
|
|
181
|
+
reason: 'transfer-read-refused'
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// The crash window's backstop: the POST landed on a previous delivery and
|
|
186
|
+
// the record did not. Adopt what exists rather than creating a second one.
|
|
187
|
+
const existing = ((_ref5 = (_transfer_body = transfer.body) == null ? void 0 : (_transfer_body_reversals = _transfer_body.reversals) == null ? void 0 : _transfer_body_reversals.data) != null ? _ref5 : []).find((item)=>{
|
|
188
|
+
var _ref;
|
|
189
|
+
var _item_metadata;
|
|
190
|
+
return String((_ref = item == null ? void 0 : (_item_metadata = item.metadata) == null ? void 0 : _item_metadata[metadataKey]) != null ? _ref : '') === causeId;
|
|
191
|
+
});
|
|
192
|
+
if (existing) {
|
|
193
|
+
var _existing_amount, _existing_id;
|
|
194
|
+
await settle(Math.round(Number((_existing_amount = existing.amount) != null ? _existing_amount : 0)), String((_existing_id = existing.id) != null ? _existing_id : ''));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const transferCents = Math.round(Number((_ref6 = (_transfer_body1 = transfer.body) == null ? void 0 : _transfer_body1.amount) != null ? _ref6 : 0));
|
|
198
|
+
const alreadyReversedCents = Math.round(Number((_ref7 = (_transfer_body2 = transfer.body) == null ? void 0 : _transfer_body2.amount_reversed) != null ? _ref7 : 0));
|
|
199
|
+
const remainingCents = Math.max(0, transferCents - alreadyReversedCents);
|
|
200
|
+
const shareCents = Math.min(Math.floor(causeAmountCents * transferCents / chargeAmountCents), remainingCents);
|
|
201
|
+
if (!(shareCents > 0)) {
|
|
202
|
+
// Not necessarily a fault — an earlier cause may already have pulled the
|
|
203
|
+
// whole transfer back — so `owedCents` is deliberately 0 rather than
|
|
204
|
+
// absent: nothing is owed, and the row says so instead of leaving the
|
|
205
|
+
// amount unknown.
|
|
206
|
+
await settle(0, null, {
|
|
207
|
+
reason: 'transfer-fully-reversed',
|
|
208
|
+
owedCents: 0
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const params = new URLSearchParams({
|
|
213
|
+
amount: String(shareCents),
|
|
214
|
+
[`metadata[${metadataKey}]`]: causeId,
|
|
215
|
+
'metadata[purchaseId]': purchaseRef.id
|
|
216
|
+
});
|
|
217
|
+
const response = await fetch(`https://api.stripe.com/v1/transfers/${transferId}/reversals`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: {
|
|
220
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
221
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
222
|
+
'Idempotency-Key': `${cause.kind}-reversal-${causeId}`
|
|
223
|
+
},
|
|
224
|
+
body: params.toString()
|
|
225
|
+
});
|
|
226
|
+
const reversal = await response.json().catch(()=>null);
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
if (isTransientStripeStatus(response.status)) {
|
|
229
|
+
throw new Error(`Stripe transfer reversal failed (${response.status}) for marketplace ${cause.kind} ${causeId}`);
|
|
230
|
+
}
|
|
231
|
+
// THE ONE THAT COSTS REAL MONEY. A definitive 4xx here — including a
|
|
232
|
+
// `balance_insufficient` on the publisher's account, which is a 400 — used
|
|
233
|
+
// to forfeit the whole share with only a log line.
|
|
234
|
+
console.error('Stripe refused the marketplace transfer reversal', reversal == null ? void 0 : reversal.error);
|
|
235
|
+
await settle(0, null, {
|
|
236
|
+
reason: 'reversal-refused',
|
|
237
|
+
owedCents: shareCents
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await settle(Math.round(Number((_ref8 = reversal == null ? void 0 : reversal.amount) != null ? _ref8 : shareCents)), String((_ref9 = reversal == null ? void 0 : reversal.id) != null ? _ref9 : ''));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* THE PUBLISHER'S SHARE OF A *PARTIAL* REFUND, PULLED BACK (AGL-2299).
|
|
245
|
+
*
|
|
246
|
+
* WHAT WAS WRONG. `charge.refunded` fires on EVERY refund; `object.refunded`
|
|
247
|
+
* is true only once the whole charge is gone. Everything above this function
|
|
248
|
+
* is gated on that flag, so a partial refund did nothing at all — no
|
|
249
|
+
* reversal, no record, not even a log line — and the note beside the branch
|
|
250
|
+
* said so deliberately: "a partial refund is a concession the ledger's split
|
|
251
|
+
* cannot decompose".
|
|
252
|
+
*
|
|
253
|
+
* That premise is false, and it is expensive. `reverseMarketplaceSellerShare`
|
|
254
|
+
* decomposes exactly this, proportionally, and has since AGL-1554. What the
|
|
255
|
+
* old branch actually did was hand the entire concession to Aglyn: on a $100
|
|
256
|
+
* sale at a 20% take rate the platform holds $20 plus the tax it owes the
|
|
257
|
+
* state, and a $50 goodwill refund is paid out of the PLATFORM's balance —
|
|
258
|
+
* the publisher's $80 already left on the destination transfer. Aglyn is
|
|
259
|
+
* $30 down and the publisher is untouched. Any partial refund larger than the
|
|
260
|
+
* take rate loses money, which is most of them.
|
|
261
|
+
*
|
|
262
|
+
* The publisher agreement §8.4 is not ambiguous about this: *"You are
|
|
263
|
+
* responsible for refunds, chargebacks, and disputes on your sales. Aglyn may
|
|
264
|
+
* reverse or withhold amounts corresponding to refunded, disputed, or
|
|
265
|
+
* fraudulent transactions."* A partial refund is a refunded amount.
|
|
266
|
+
*
|
|
267
|
+
* THE ENTITLEMENT IS NOT TOUCHED, and that part of the old comment stands: a
|
|
268
|
+
* partial refund is a concession, not an un-buy. No `refundedAt`, so
|
|
269
|
+
* `hasLivePurchase` still says the buyer owns it, and no GA4 `refund` — that
|
|
270
|
+
* event nets the WHOLE transaction out of the platform-net accounting
|
|
271
|
+
* (AGL-1850), which a partial refund did not do. Only the money moves.
|
|
272
|
+
*
|
|
273
|
+
* WHY THIS IS A SECOND FUNCTION RATHER THAN A FLAG ON THE FIRST.
|
|
274
|
+
* `reverseMarketplaceSellerShare` is ONE-SHOT by design — `reversedTransferCents`
|
|
275
|
+
* present means "this purchase's pull-back has settled", and that marker is
|
|
276
|
+
* what stops a refund following a lost dispute from debiting a publisher
|
|
277
|
+
* twice. Partial refunds are the opposite shape: there can be several, and
|
|
278
|
+
* each must add to what came back. Teaching one function both disciplines is
|
|
279
|
+
* how the settle marker stops meaning anything.
|
|
280
|
+
*
|
|
281
|
+
* IDEMPOTENT WITHOUT A MARKER, because Stripe already keeps the ledger.
|
|
282
|
+
* The target is derived from the charge's CUMULATIVE `amount_refunded`, and
|
|
283
|
+
* what has already come back is `transfer.amount_reversed` — Stripe's own
|
|
284
|
+
* number, not ours:
|
|
285
|
+
*
|
|
286
|
+
* target = floor(amount_refunded × transfer.amount ÷ charge.amount)
|
|
287
|
+
* toReverse = clamp(target, 0, transfer.amount) − transfer.amount_reversed
|
|
288
|
+
*
|
|
289
|
+
* A redelivery recomputes the same target, finds `amount_reversed` already
|
|
290
|
+
* there, and reverses nothing. Three partial refunds converge on the right
|
|
291
|
+
* total whatever order they arrive in. And it composes with the one-shot path
|
|
292
|
+
* in both directions: a later FULL refund computes
|
|
293
|
+
* `min(transfer.amount, remaining)` and takes only the remainder, while a
|
|
294
|
+
* dispute takes its own proportional slice of what is left.
|
|
295
|
+
*
|
|
296
|
+
* FAILURES DO NOT SETTLE ANYTHING. Transient (429/5xx/network) throws so the
|
|
297
|
+
* redelivery is the retry, exactly as the one-shot path does. A definitive
|
|
298
|
+
* 4xx — `balance_insufficient` on a publisher's account is a 400 — records
|
|
299
|
+
* itself in AGL-2140's existing recovery queue (`reversalFailedAt`,
|
|
300
|
+
* `reversalFailedReason`, `reversalOwedCents`) and returns 200 so the endpoint
|
|
301
|
+
* does not redeliver forever. Nothing is permanently forfeited by that: the
|
|
302
|
+
* next partial refund on the same charge recomputes the target from scratch
|
|
303
|
+
* and picks the shortfall back up.
|
|
304
|
+
*/ async function reverseMarketplacePartialRefundShare(purchaseRef, chargeId) {
|
|
305
|
+
var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
306
|
+
var _charge_body, _charge_body1, _charge_body2, _transfer_body, _transfer_body1;
|
|
307
|
+
if (!chargeId) return;
|
|
308
|
+
const snapshot = await purchaseRef.get();
|
|
309
|
+
if (!snapshot.exists) return;
|
|
310
|
+
// The one-shot path already settled this purchase — a lost dispute, or a
|
|
311
|
+
// full refund that landed first. Its marker means the pull-back is closed.
|
|
312
|
+
if (snapshot.get('reversedTransferCents') != null) return;
|
|
313
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
314
|
+
if (!stripeKey) {
|
|
315
|
+
console.error('Marketplace partial-refund reversal skipped: STRIPE_SECRET_KEY is not set (AGL-2299)');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// The charge is RE-READ rather than taken from the event, for the same
|
|
319
|
+
// reason the one-shot path re-reads it: the event is a snapshot, and the
|
|
320
|
+
// number that decides this reversal is the charge's CUMULATIVE
|
|
321
|
+
// `amount_refunded`. A redelivery days later, or a drain of a parked
|
|
322
|
+
// partial, must converge on what is true now — not on what was true when
|
|
323
|
+
// the event was minted. It also means an API version that trims `transfer`
|
|
324
|
+
// out of the webhook payload cannot silently turn this into a no-op.
|
|
325
|
+
const charge = await stripeGet(`https://api.stripe.com/v1/charges/${chargeId}`, stripeKey);
|
|
326
|
+
if (!charge.ok) {
|
|
327
|
+
var _charge_body3;
|
|
328
|
+
if (isTransientStripeStatus(charge.status)) {
|
|
329
|
+
throw new Error(`Stripe charge read failed (${charge.status}) for marketplace partial refund on ${chargeId}`);
|
|
330
|
+
}
|
|
331
|
+
console.error('Stripe refused the charge read', (_charge_body3 = charge.body) == null ? void 0 : _charge_body3.error);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const chargeAmountCents = Math.round(Number((_ref = (_charge_body = charge.body) == null ? void 0 : _charge_body.amount) != null ? _ref : 0));
|
|
335
|
+
const refundedCents = Math.round(Number((_ref1 = (_charge_body1 = charge.body) == null ? void 0 : _charge_body1.amount_refunded) != null ? _ref1 : 0));
|
|
336
|
+
if (!(chargeAmountCents > 0) || !(refundedCents > 0)) return;
|
|
337
|
+
const transferId = String((_ref2 = (_charge_body2 = charge.body) == null ? void 0 : _charge_body2.transfer) != null ? _ref2 : '');
|
|
338
|
+
if (!transferId) {
|
|
339
|
+
// No destination transfer means no seller share to pull back — a
|
|
340
|
+
// marketplace charge always has one, so this is a charge we do not own.
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${transferId}`, stripeKey);
|
|
344
|
+
if (!transfer.ok) {
|
|
345
|
+
var _transfer_body2;
|
|
346
|
+
if (isTransientStripeStatus(transfer.status)) {
|
|
347
|
+
throw new Error(`Stripe transfer read failed (${transfer.status}) for marketplace partial refund on ${chargeId}`);
|
|
348
|
+
}
|
|
349
|
+
console.error('Stripe refused the transfer read', (_transfer_body2 = transfer.body) == null ? void 0 : _transfer_body2.error);
|
|
350
|
+
await purchaseRef.set({
|
|
351
|
+
partialRefundedCents: refundedCents,
|
|
352
|
+
reversalFailedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
|
|
353
|
+
reversalFailedReason: 'transfer-read-refused',
|
|
354
|
+
reversalFailedCause: 'partial-refund'
|
|
355
|
+
}, {
|
|
356
|
+
merge: true
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const transferCents = Math.round(Number((_ref3 = (_transfer_body = transfer.body) == null ? void 0 : _transfer_body.amount) != null ? _ref3 : 0));
|
|
361
|
+
const alreadyReversedCents = Math.round(Number((_ref4 = (_transfer_body1 = transfer.body) == null ? void 0 : _transfer_body1.amount_reversed) != null ? _ref4 : 0));
|
|
362
|
+
const targetCents = Math.min(transferCents, Math.floor(refundedCents * transferCents / chargeAmountCents));
|
|
363
|
+
const toReverseCents = targetCents - alreadyReversedCents;
|
|
364
|
+
if (!(toReverseCents > 0)) {
|
|
365
|
+
// Already square — a redelivery, or an earlier cause that took more than
|
|
366
|
+
// this refund's proportional slice. Record what the buyer has had back so
|
|
367
|
+
// the seller panel and the ledger agree with Stripe.
|
|
368
|
+
await purchaseRef.set({
|
|
369
|
+
partialRefundedCents: refundedCents,
|
|
370
|
+
partialReversedTransferCents: alreadyReversedCents
|
|
371
|
+
}, {
|
|
372
|
+
merge: true
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const response = await fetch(`https://api.stripe.com/v1/transfers/${transferId}/reversals`, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: {
|
|
379
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
380
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
381
|
+
// Derived from the charge AND the cumulative target, so a redelivery
|
|
382
|
+
// of the same event replays Stripe's stored response while a genuinely
|
|
383
|
+
// larger second refund gets its own call. Belt and braces: the
|
|
384
|
+
// `amount_reversed` read above already turns a redelivery into a
|
|
385
|
+
// no-op, and this closes the window where the POST landed and our
|
|
386
|
+
// record did not.
|
|
387
|
+
'Idempotency-Key': `partial-reversal-${chargeId}-${targetCents}`
|
|
388
|
+
},
|
|
389
|
+
body: new URLSearchParams({
|
|
390
|
+
amount: String(toReverseCents),
|
|
391
|
+
'metadata[partialRefundOfCharge]': chargeId,
|
|
392
|
+
'metadata[purchaseId]': purchaseRef.id
|
|
393
|
+
}).toString()
|
|
394
|
+
});
|
|
395
|
+
const reversal = await response.json().catch(()=>null);
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
if (isTransientStripeStatus(response.status)) {
|
|
398
|
+
throw new Error(`Stripe partial transfer reversal failed (${response.status}) on ${chargeId}`);
|
|
399
|
+
}
|
|
400
|
+
console.error('Marketplace partial-refund seller share NOT reversed — recorded for recovery (AGL-2299)', {
|
|
401
|
+
purchaseId: purchaseRef.id,
|
|
402
|
+
chargeId,
|
|
403
|
+
owedCents: toReverseCents
|
|
404
|
+
});
|
|
405
|
+
await purchaseRef.set({
|
|
406
|
+
partialRefundedCents: refundedCents,
|
|
407
|
+
partialReversedTransferCents: alreadyReversedCents,
|
|
408
|
+
reversalFailedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
|
|
409
|
+
reversalFailedReason: 'reversal-refused',
|
|
410
|
+
reversalFailedCause: 'partial-refund',
|
|
411
|
+
reversalOwedCents: toReverseCents
|
|
412
|
+
}, {
|
|
413
|
+
merge: true
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
await purchaseRef.set(_extends({
|
|
418
|
+
partialRefundedCents: refundedCents,
|
|
419
|
+
partialReversedTransferCents: alreadyReversedCents + Math.round(Number((_ref5 = reversal == null ? void 0 : reversal.amount) != null ? _ref5 : toReverseCents))
|
|
420
|
+
}, (reversal == null ? void 0 : reversal.id) ? {
|
|
421
|
+
partialTransferReversalId: String(reversal.id)
|
|
422
|
+
} : {}), {
|
|
423
|
+
merge: true
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* WHERE A REFUND THAT ARRIVED TOO EARLY WAITS (AGL-2148).
|
|
428
|
+
*
|
|
429
|
+
* THE WINDOW IS REAL, not exotic. `checkout.session.completed` is what writes
|
|
430
|
+
* `marketplacePurchases/{sessionId}`, and that delivery RETRIES: this endpoint
|
|
431
|
+
* 500s on every transient Stripe failure raised in this file and on any throw
|
|
432
|
+
* from a sibling plugin handler, and Stripe then redelivers for up to three
|
|
433
|
+
* days. A dashboard refund issued inside that window joins on the payment
|
|
434
|
+
* intent, finds nothing, and — before this — the branch simply ended. No
|
|
435
|
+
* `else`. The buyer got their money, the publisher kept their 80%, Aglyn ate
|
|
436
|
+
* the gross, and `hasLivePurchase` saw no `refundedAt`, so the refunded buyer
|
|
437
|
+
* kept the install forever.
|
|
438
|
+
*
|
|
439
|
+
* WHY NOT SIMPLY THROW so Stripe redelivers the refund. Because
|
|
440
|
+
* `charge.refunded` and `charge.dispute.*` arrive on this same endpoint for
|
|
441
|
+
* storefront orders and subscription charges, whose payment intents will never
|
|
442
|
+
* match a marketplace purchase. Throwing on "not found" would 500 the webhook
|
|
443
|
+
* for every non-marketplace refund, and the route drops its idempotency claim
|
|
444
|
+
* on a throw — so the commerce and bookings handlers' non-idempotent effects
|
|
445
|
+
* would re-run. The containment would cost more than the defect.
|
|
446
|
+
*
|
|
447
|
+
* SO THE EVENT IS PARKED, and the session landing drains it. Keyed by PAYMENT
|
|
448
|
+
* INTENT, which is the only id the refund door, the dispute door and the
|
|
449
|
+
* session landing all carry: the drain is one `get()` on a known document id,
|
|
450
|
+
* with no query and no composite index.
|
|
451
|
+
*
|
|
452
|
+
* NOTHING IS PARKED SPECULATIVELY. Both doors gate the write on a
|
|
453
|
+
* marketplace discriminator (`metadata.type`, stamped on the PaymentIntent at
|
|
454
|
+
* checkout — see checkout.ts), so a commerce refund writes no orphan at all.
|
|
455
|
+
* That is why this store needs no TTL and no sweeper: a document here means a
|
|
456
|
+
* marketplace purchase is mid-flight, and the session landing removes it.
|
|
457
|
+
*/ const REFUND_ORPHAN_COLLECTION = 'marketplaceRefundOrphans';
|
|
458
|
+
/**
|
|
459
|
+
* The three effects a full refund or a LOST dispute applies to a purchase:
|
|
460
|
+
* revoke, report, pull the seller's share back.
|
|
461
|
+
*
|
|
462
|
+
* ONE function for three call sites — the refund door, the dispute door, and
|
|
463
|
+
* the orphan drain — deliberately, and it is the AGL-1994 lesson again: the
|
|
464
|
+
* drain must apply *the same* effects the door would have, and a second copy
|
|
465
|
+
* of them is how the two drift. The drain therefore cannot forget the GA hit
|
|
466
|
+
* or the reversal, because it is not in a position to.
|
|
467
|
+
*
|
|
468
|
+
* IDEMPOTENT AT EVERY LAYER. `refundedAt` read BEFORE the stamp is the GA
|
|
469
|
+
* guard (a redelivery reports nothing twice); `reversedTransferCents` is the
|
|
470
|
+
* reversal's settle marker, so a redelivery — or a drain running after the
|
|
471
|
+
* door already ran — returns before any Stripe call; and the reversal POST
|
|
472
|
+
* carries `Idempotency-Key: <kind>-reversal-<causeId>`, which is the SAME key
|
|
473
|
+
* either path would have used because it is derived from the cause, not from
|
|
474
|
+
* who is applying it. A drained refund and a redelivered `charge.refunded`
|
|
475
|
+
* are therefore indistinguishable to Stripe, and the publisher cannot be
|
|
476
|
+
* debited twice.
|
|
477
|
+
*/ async function applyMarketplaceRefundOutcome(purchaseRef, cause, /** Dispute-only fields stamped in the same write. */ extraStamp = {}) {
|
|
478
|
+
const snapshot = await purchaseRef.get();
|
|
479
|
+
if (!snapshot.exists) return;
|
|
480
|
+
// Read BEFORE the stamp: `refundedAt` doubles as the GA guard, so a
|
|
481
|
+
// redelivery that slips past the route's event claim finds the purchase
|
|
482
|
+
// already refunded and reports nothing a second time.
|
|
483
|
+
const alreadyRefunded = Boolean(snapshot.get('refundedAt'));
|
|
484
|
+
await purchaseRef.set(_extends({}, extraStamp, {
|
|
485
|
+
refundedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
|
|
486
|
+
refundedCents: cause.amountCents
|
|
487
|
+
}), {
|
|
488
|
+
merge: true
|
|
489
|
+
});
|
|
490
|
+
// GA4 `refund` (AGL-1850) — the reversal of the AGL-1639 purchase, in the
|
|
491
|
+
// SAME accounting. The purchase reported the platform NET (gross − tax −
|
|
492
|
+
// transfer), so the refund must reverse that number: refunding the
|
|
493
|
+
// tax-inclusive gross would net MORE out of GA than the sale ever put in.
|
|
494
|
+
// Fire-and-forget, after the stamp: an analytics failure must never
|
|
495
|
+
// un-claim a Stripe event.
|
|
496
|
+
if (!alreadyRefunded) {
|
|
497
|
+
var _snapshot_get, _snapshot_get1, _snapshot_get2;
|
|
498
|
+
const grossCents = Number((_snapshot_get = snapshot.get('amountCents')) != null ? _snapshot_get : 0);
|
|
499
|
+
const taxCents = Number((_snapshot_get1 = snapshot.get('taxCents')) != null ? _snapshot_get1 : 0);
|
|
500
|
+
const sellerCents = Number((_snapshot_get2 = snapshot.get('transferCents')) != null ? _snapshot_get2 : 0);
|
|
501
|
+
const netCents = grossCents - taxCents - sellerCents;
|
|
502
|
+
if (netCents > 0) {
|
|
503
|
+
after(()=>{
|
|
504
|
+
var _snapshot_get;
|
|
505
|
+
return sendGa4Refund({
|
|
506
|
+
transactionId: String(purchaseRef.id),
|
|
507
|
+
value: netCents / 100,
|
|
508
|
+
currency: cause.currency,
|
|
509
|
+
items: [],
|
|
510
|
+
stripeCustomerId: cause.stripeCustomerId || String((_snapshot_get = snapshot.get('buyerUid')) != null ? _snapshot_get : '')
|
|
511
|
+
}).catch(()=>undefined);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// AFTER the revocation, so the entitlement never stays live because a
|
|
516
|
+
// Stripe read failed: this half throws on transient failures (the
|
|
517
|
+
// redelivery is the retry, and everything above is idempotent under it) and
|
|
518
|
+
// settles definitively otherwise.
|
|
519
|
+
await reverseMarketplaceSellerShare(purchaseRef, cause);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Parks a cause whose purchase document does not exist yet.
|
|
523
|
+
*
|
|
524
|
+
* FIRST CAUSE WINS. The document is not merged over: a redelivery of the same
|
|
525
|
+
* refund would otherwise move `createdAt`, and the (vanishingly unlikely)
|
|
526
|
+
* refund-then-dispute pair inside one window would otherwise blend into a
|
|
527
|
+
* chimera carrying one cause's id and the other's amount. The first cause is
|
|
528
|
+
* the one that revoked, and the reversal settle marker means only one
|
|
529
|
+
* pull-back can happen regardless of which is replayed.
|
|
530
|
+
*/ async function recordRefundOrphan(firestore, cause) {
|
|
531
|
+
if (!cause.paymentIntentId || !cause.id) return;
|
|
532
|
+
const orphanRef = firestore.collection(REFUND_ORPHAN_COLLECTION).doc(cause.paymentIntentId);
|
|
533
|
+
const parked = await orphanRef.get();
|
|
534
|
+
// FIRST CAUSE WINS — with one exception (AGL-2299). A parked
|
|
535
|
+
// `partial-refund` is strictly weaker than a full refund or a lost dispute:
|
|
536
|
+
// it moves a slice of the seller's share and nothing else. If the same
|
|
537
|
+
// charge is then refunded in full inside the same window, the stronger
|
|
538
|
+
// cause must replace it, or the drain would pull back a slice and leave the
|
|
539
|
+
// buyer entitled to content they have been fully refunded for. The reverse
|
|
540
|
+
// never happens: a fully refunded charge cannot take a partial afterwards.
|
|
541
|
+
if (parked.exists && !(parked.get('kind') === 'partial-refund' && cause.kind !== 'partial-refund')) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
await orphanRef.set({
|
|
545
|
+
kind: cause.kind,
|
|
546
|
+
id: cause.id,
|
|
547
|
+
paymentIntentId: cause.paymentIntentId,
|
|
548
|
+
amountCents: cause.amountCents,
|
|
549
|
+
chargeId: cause.chargeId,
|
|
550
|
+
currency: cause.currency,
|
|
551
|
+
stripeCustomerId: cause.stripeCustomerId,
|
|
552
|
+
createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
553
|
+
}, {
|
|
554
|
+
merge: true
|
|
555
|
+
});
|
|
556
|
+
// Loud on purpose: this is money in flight. If the matching session never
|
|
557
|
+
// lands (a buyer who abandoned checkout cannot be refunded, so in practice
|
|
558
|
+
// it always does), the row is the only trace, and it is queryable.
|
|
559
|
+
console.error('Marketplace refund landed before its purchase — parked for the session handler (AGL-2148)', {
|
|
560
|
+
kind: cause.kind,
|
|
561
|
+
causeId: cause.id,
|
|
562
|
+
paymentIntentId: cause.paymentIntentId,
|
|
563
|
+
amountCents: cause.amountCents
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Applies — and removes — a cause parked before this purchase existed.
|
|
568
|
+
*
|
|
569
|
+
* WHETHER THE SESSION PATH MAY REVOKE AND REVERSE FOR A CAUSE IT DID NOT
|
|
570
|
+
* RECEIVE: yes, and it is not really a choice. The alternative is a purchase
|
|
571
|
+
* document that reads as live for money the buyer already got back — a free
|
|
572
|
+
* install and a publisher paid for a sale that was undone.
|
|
573
|
+
*
|
|
574
|
+
* THE DELETE IS LAST, after the reversal returns. A transient Stripe failure
|
|
575
|
+
* inside `reverseMarketplaceSellerShare` THROWS on purpose so the session
|
|
576
|
+
* delivery 500s and Stripe redelivers; if the orphan were dropped first, that
|
|
577
|
+
* redelivery would find nothing to replay and the reversal would be lost
|
|
578
|
+
* permanently. Deleting after a DEFINITIVE settle is correct and deliberate:
|
|
579
|
+
* that settle wrote `reversalFailedAt` / `reversalFailedReason` /
|
|
580
|
+
* `reversalOwedCents` onto the purchase, so the money lands in AGL-2140's
|
|
581
|
+
* existing recovery queue rather than in a second one invented here.
|
|
582
|
+
*/ async function drainMarketplaceRefundOrphan(firestore, purchaseRef, paymentIntentId) {
|
|
583
|
+
var _orphan_get, _orphan_get1;
|
|
584
|
+
if (!paymentIntentId) return;
|
|
585
|
+
const orphanRef = firestore.collection(REFUND_ORPHAN_COLLECTION).doc(paymentIntentId);
|
|
586
|
+
const orphan = await orphanRef.get();
|
|
587
|
+
if (!orphan.exists) return;
|
|
588
|
+
const causeId = String((_orphan_get = orphan.get('id')) != null ? _orphan_get : '');
|
|
589
|
+
const parkedKind = String((_orphan_get1 = orphan.get('kind')) != null ? _orphan_get1 : '');
|
|
590
|
+
if (parkedKind === 'partial-refund') {
|
|
591
|
+
var _orphan_get2;
|
|
592
|
+
// A partial refund never revokes and never reports, so it does not go
|
|
593
|
+
// through `applyMarketplaceRefundOutcome` (AGL-2299). The charge is RE-READ
|
|
594
|
+
// from Stripe rather than replayed from the parked amount: the target is
|
|
595
|
+
// computed from the charge's cumulative `amount_refunded`, and by the time
|
|
596
|
+
// this drains there may have been a second refund the parked document
|
|
597
|
+
// never saw.
|
|
598
|
+
// Throws on a transient Stripe failure, which leaves the orphan in place
|
|
599
|
+
// for the redelivery — the delete below is exactly what must not happen
|
|
600
|
+
// in that case, and it is why it is AFTER this await rather than before.
|
|
601
|
+
await reverseMarketplacePartialRefundShare(purchaseRef, String((_orphan_get2 = orphan.get('chargeId')) != null ? _orphan_get2 : ''));
|
|
602
|
+
await orphanRef.delete();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const kind = parkedKind === 'dispute' ? 'dispute' : 'refund';
|
|
606
|
+
if (causeId) {
|
|
607
|
+
var _orphan_get3, _orphan_get4, _orphan_get5, _orphan_get6;
|
|
608
|
+
await applyMarketplaceRefundOutcome(purchaseRef, {
|
|
609
|
+
kind,
|
|
610
|
+
id: causeId,
|
|
611
|
+
paymentIntentId,
|
|
612
|
+
amountCents: Math.round(Number((_orphan_get3 = orphan.get('amountCents')) != null ? _orphan_get3 : 0)),
|
|
613
|
+
chargeId: String((_orphan_get4 = orphan.get('chargeId')) != null ? _orphan_get4 : ''),
|
|
614
|
+
currency: String((_orphan_get5 = orphan.get('currency')) != null ? _orphan_get5 : 'usd'),
|
|
615
|
+
stripeCustomerId: String((_orphan_get6 = orphan.get('stripeCustomerId')) != null ? _orphan_get6 : '')
|
|
616
|
+
}, // A parked dispute is always a LOST one — the only dispute state that
|
|
617
|
+
// moves money — so the outcome lands on the record too, exactly as the
|
|
618
|
+
// dispute door would have stamped it.
|
|
619
|
+
kind === 'dispute' ? {
|
|
620
|
+
disputeId: causeId,
|
|
621
|
+
disputeStatus: 'lost'
|
|
622
|
+
} : {});
|
|
623
|
+
}
|
|
624
|
+
await orphanRef.delete();
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* A dispute has no metadata of ours to read — the event object is the DISPUTE,
|
|
628
|
+
* not the charge — so the discriminator costs one Stripe read.
|
|
629
|
+
*
|
|
630
|
+
* Only ever reached when the payment-intent join came up empty, so a normal
|
|
631
|
+
* marketplace dispute (purchase present) pays nothing for it, and a commerce
|
|
632
|
+
* or subscription dispute pays one GET before being correctly ignored.
|
|
633
|
+
* Disputes are rare enough for that to be the right trade against parking an
|
|
634
|
+
* orphan for every chargeback on the platform.
|
|
635
|
+
*
|
|
636
|
+
* A FAILED READ DOES NOT THROW, deliberately, and this is the one place the
|
|
637
|
+
* fix accepts a loss. Throwing would 500 the endpoint for a dispute we have
|
|
638
|
+
* not established is ours — the exact blast radius that ruled out "just throw"
|
|
639
|
+
* in the first place — and the route drops its idempotency claim on a throw.
|
|
640
|
+
* So a marketplace dispute landing inside the purchase window WHILE the charge
|
|
641
|
+
* read fails loses its orphan. It is logged, and it is two independent
|
|
642
|
+
* unlikely events deep.
|
|
643
|
+
*/ async function recordDisputeOrphanIfMarketplace(firestore, object, paymentIntentId) {
|
|
644
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
645
|
+
var _charge_body_metadata, _charge_body;
|
|
646
|
+
const chargeId = String((_ref = object == null ? void 0 : object.charge) != null ? _ref : '');
|
|
647
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
648
|
+
if (!chargeId || !stripeKey) return false;
|
|
649
|
+
const charge = await stripeGet(`https://api.stripe.com/v1/charges/${chargeId}`, stripeKey);
|
|
650
|
+
if (!charge.ok) {
|
|
651
|
+
console.error('Could not classify a dispute with no matching purchase — orphan NOT parked (AGL-2148)', {
|
|
652
|
+
chargeId,
|
|
653
|
+
paymentIntentId,
|
|
654
|
+
status: charge.status
|
|
655
|
+
});
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
if (((_charge_body = charge.body) == null ? void 0 : (_charge_body_metadata = _charge_body.metadata) == null ? void 0 : _charge_body_metadata.type) !== 'marketplace-purchase') return false;
|
|
659
|
+
await recordRefundOrphan(firestore, {
|
|
660
|
+
kind: 'dispute',
|
|
661
|
+
id: String((_ref1 = object == null ? void 0 : object.id) != null ? _ref1 : ''),
|
|
662
|
+
paymentIntentId,
|
|
663
|
+
amountCents: Math.round(Number((_ref2 = object == null ? void 0 : object.amount) != null ? _ref2 : 0)),
|
|
664
|
+
chargeId,
|
|
665
|
+
currency: String((_ref3 = object == null ? void 0 : object.currency) != null ? _ref3 : 'usd'),
|
|
666
|
+
stripeCustomerId: ''
|
|
667
|
+
});
|
|
668
|
+
// Parked, so it IS marketplace's — the route must not also alert on it
|
|
669
|
+
// (AGL-2429). The three early exits above return false on purpose: two of
|
|
670
|
+
// them mean "could not classify", and a dispute nobody could classify is
|
|
671
|
+
// exactly what the route's unattributed alert exists to catch.
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Marketplace-purchase section of the platform Stripe webhook (AGL-46/418):
|
|
676
|
+
* keyed by session id (idempotent on Stripe redelivery) — relocated
|
|
677
|
+
* verbatim from the console route; registered via
|
|
678
|
+
* registerMarketplaceConsoleApi. Install gating and the seller ledger read
|
|
679
|
+
* these purchase docs.
|
|
680
|
+
*/ export const marketplaceBillingWebhookHandler = async ({ type, object, event })=>{
|
|
681
|
+
var _ref;
|
|
682
|
+
var _object_metadata;
|
|
683
|
+
// Connect readiness, kept fresh (AGL-1997) — the publisher twin of the
|
|
684
|
+
// commerce sync. The seller panel reads `stripeChargesEnabled` /
|
|
685
|
+
// `stripePayoutsEnabled` off this document, and before this nothing but the
|
|
686
|
+
// publisher reopening the connect route ever refreshed either. Same early
|
|
687
|
+
// return: `account.updated` shares nothing with the purchase sections below.
|
|
688
|
+
// `event.livemode`, not `object.livemode` (AGL-2471) — the Account object
|
|
689
|
+
// has no such field. Two of the three poisoned production linkages were
|
|
690
|
+
// publisherProfiles, and this is the path that heals them.
|
|
691
|
+
if (type === 'account.updated') {
|
|
692
|
+
await syncConnectAccountStatus('publisherProfiles', object, event == null ? void 0 : event.livemode);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// A PAYOUT OR TRANSFER THAT NEVER LANDED.
|
|
696
|
+
//
|
|
697
|
+
// Placed beside `account.updated` because it is the same kind of event —
|
|
698
|
+
// account-level, nothing to do with the `metadata.type` order sections
|
|
699
|
+
// below — and returns for the same reason.
|
|
700
|
+
//
|
|
701
|
+
// `payout.failed` is the CONNECTED account's balance failing to reach its
|
|
702
|
+
// bank, so the account id is `event.account`: the Payout object's own
|
|
703
|
+
// `destination` names the bank, not the Connect account. `transfer.failed`
|
|
704
|
+
// is the platform's balance failing to reach the connected account, a
|
|
705
|
+
// platform event whose `destination` IS the account.
|
|
706
|
+
//
|
|
707
|
+
// Recorded and surfaced, never retried: Stripe runs its own retry schedule
|
|
708
|
+
// and a second transfer against an account that just refused one is how a
|
|
709
|
+
// duplicate lands.
|
|
710
|
+
if (type === 'payout.failed' || type === 'transfer.failed') {
|
|
711
|
+
var _ref1, _ref2, _ref3;
|
|
712
|
+
var _object_destination;
|
|
713
|
+
const failedAccountId = type === 'payout.failed' ? String((_ref1 = event == null ? void 0 : event.account) != null ? _ref1 : '') : String((_ref2 = (_ref3 = object == null ? void 0 : (_object_destination = object.destination) == null ? void 0 : _object_destination.id) != null ? _ref3 : object == null ? void 0 : object.destination) != null ? _ref2 : '');
|
|
714
|
+
await recordConnectPayoutFailure('publisherProfiles', {
|
|
715
|
+
kind: type === 'payout.failed' ? 'payout' : 'transfer',
|
|
716
|
+
object,
|
|
717
|
+
accountId: failedAccountId,
|
|
718
|
+
livemode: event == null ? void 0 : event.livemode
|
|
719
|
+
});
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// A later success retires the warning the card shows. The history in
|
|
723
|
+
// `connectPayoutFailures` is kept — "has this account failed before" is what
|
|
724
|
+
// that record exists to answer — but a stale warning on a resolved problem
|
|
725
|
+
// trains people to ignore the surface.
|
|
726
|
+
if (type === 'payout.paid') {
|
|
727
|
+
var _ref4;
|
|
728
|
+
await clearConnectPayoutFailure('publisherProfiles', String((_ref4 = event == null ? void 0 : event.account) != null ? _ref4 : ''));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Marketplace purchases (AGL-46): keyed by session id (idempotent on
|
|
732
|
+
// Stripe redelivery). Install gating and the seller ledger read these.
|
|
733
|
+
if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.type) === 'marketplace-purchase' && (object == null ? void 0 : object.payment_status) === 'paid') {
|
|
734
|
+
var _object_metadata1;
|
|
735
|
+
// Sellers are orgs (AGL-652) — the ledger records which ORG earned it.
|
|
736
|
+
const { listingId, buyerUid, buyerOrgId, sellerOrgId, feeCents, transferCents } = (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {};
|
|
737
|
+
if (listingId && buyerUid && sellerOrgId) {
|
|
738
|
+
var _ref5, _ref6, _ref7, _ref8;
|
|
739
|
+
var _object_total_details;
|
|
740
|
+
// The remittance-correct split (AGL-1544), read ONCE and used by both
|
|
741
|
+
// the ledger and the GA hit (AGL-1639) — the two must not be able to
|
|
742
|
+
// describe the same sale differently.
|
|
743
|
+
//
|
|
744
|
+
// `amount_total` is the tax-inclusive GROSS the buyer paid. Out of it:
|
|
745
|
+
// `taxCents` is what the PLATFORM owes the state (collected under the
|
|
746
|
+
// marketplace-provider registration, never ours), and `sellerCents` is
|
|
747
|
+
// the fixed transfer the seller's Connect account received (their
|
|
748
|
+
// share of the pre-tax price). What is left is what Aglyn keeps.
|
|
749
|
+
const grossCents = Number((_ref5 = object == null ? void 0 : object.amount_total) != null ? _ref5 : 0);
|
|
750
|
+
const taxCents = Number((_ref6 = object == null ? void 0 : (_object_total_details = object.total_details) == null ? void 0 : _object_total_details.amount_tax) != null ? _ref6 : 0);
|
|
751
|
+
const sellerCents = Number(transferCents != null ? transferCents : 0);
|
|
752
|
+
const netCents = grossCents - taxCents - sellerCents;
|
|
753
|
+
// WHERE that tax is owed. Read from the session Stripe computed it
|
|
754
|
+
// from — see `buyerTaxJurisdiction`.
|
|
755
|
+
const jurisdiction = buyerTaxJurisdiction(object);
|
|
756
|
+
const purchaseRef = firebaseAdmin.app().firestore().collection('marketplacePurchases').doc(String(object.id));
|
|
757
|
+
// `createdAt` is stamped ONCE (AGL-2109). Merging the write below fixed
|
|
758
|
+
// the erasure but introduced its own drift: `serverTimestamp()` on a
|
|
759
|
+
// redelivery would move a three-day-old sale to today, which is the
|
|
760
|
+
// field the seller ledger and every revenue period read. One read, on a
|
|
761
|
+
// path that already makes several Stripe round trips, buys a date that
|
|
762
|
+
// means what it says.
|
|
763
|
+
const alreadyRecorded = (await purchaseRef.get()).exists;
|
|
764
|
+
await purchaseRef.set(_extends({
|
|
765
|
+
listingId,
|
|
766
|
+
buyerUid
|
|
767
|
+
}, buyerOrgId ? {
|
|
768
|
+
buyerOrgId: String(buyerOrgId)
|
|
769
|
+
} : {}, {
|
|
770
|
+
sellerOrgId,
|
|
771
|
+
// Gross − tax − transfer = the platform fee, which feeCents also
|
|
772
|
+
// records independently from the rate resolved at checkout.
|
|
773
|
+
amountCents: grossCents,
|
|
774
|
+
feeCents: Number(feeCents != null ? feeCents : 0),
|
|
775
|
+
taxCents,
|
|
776
|
+
transferCents: sellerCents,
|
|
777
|
+
// The refund trail (AGL-1546): `charge.refunded` carries the
|
|
778
|
+
// payment intent, not the session — without this id a refund
|
|
779
|
+
// could never find the purchase it revokes.
|
|
780
|
+
paymentIntentId: String((_ref7 = object == null ? void 0 : object.payment_intent) != null ? _ref7 : '')
|
|
781
|
+
}, alreadyRecorded ? {} : _extends({
|
|
782
|
+
createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
783
|
+
}, jurisdiction ? {
|
|
784
|
+
customerAddress: jurisdiction
|
|
785
|
+
} : {})), // MERGED, like every other write on this path (AGL-2109). This one
|
|
786
|
+
// was a full document REPLACE, and the document it replaces is not
|
|
787
|
+
// only the sale: it is the buyer's ENTITLEMENT (`hasLivePurchase`
|
|
788
|
+
// reads `refundedAt`), the refund trail, the dispute trail, and the
|
|
789
|
+
// transfer-reversal SETTLE MARKER (`reversedTransferCents`, which
|
|
790
|
+
// `reverseMarketplaceSellerShare` short-circuits on so a publisher is
|
|
791
|
+
// never debited twice).
|
|
792
|
+
//
|
|
793
|
+
// Stripe redelivers `checkout.session.completed` for up to three days
|
|
794
|
+
// after any 500, and this endpoint 500s on purpose — for every
|
|
795
|
+
// transient Stripe failure raised in this file and for any throw from
|
|
796
|
+
// a sibling plugin handler that runs after this one
|
|
797
|
+
// (`runBillingWebhookHandlers` awaits them in sequence and lets a
|
|
798
|
+
// throw propagate). So the redelivery is expected traffic, not an
|
|
799
|
+
// exotic race. Landing after a refund it erased `refundedAt` — the
|
|
800
|
+
// refunded buyer silently got their install back — and erased
|
|
801
|
+
// `reversedTransferCents`, re-opening a second reversal against the
|
|
802
|
+
// publisher's Connect account.
|
|
803
|
+
//
|
|
804
|
+
// Merging is safe in the other direction too: every field written
|
|
805
|
+
// here is derived from the session event, so a redelivery restamps
|
|
806
|
+
// identical values.
|
|
807
|
+
{
|
|
808
|
+
merge: true
|
|
809
|
+
});
|
|
810
|
+
// Marketplace sales are real revenue and belong in the same GA
|
|
811
|
+
// `purchase` stream as subscriptions (AGL-1561), separated by
|
|
812
|
+
// `item_category` so plugin revenue and subscription revenue can be
|
|
813
|
+
// read apart or together.
|
|
814
|
+
//
|
|
815
|
+
// After the ledger write, and fire-and-forget: the ledger is what
|
|
816
|
+
// grants the install entitlement, and an analytics failure must never
|
|
817
|
+
// throw here — the route deletes its idempotency claim on any throw,
|
|
818
|
+
// which would make Stripe redeliver a purchase that already landed.
|
|
819
|
+
//
|
|
820
|
+
// `transaction_id` is the checkout session id — the same key the
|
|
821
|
+
// ledger doc uses — so GA de-duplicates a redelivery exactly as
|
|
822
|
+
// Firestore does.
|
|
823
|
+
// WHAT COUNTS AS REVENUE ON A MARKETPLACE SALE (AGL-1639)
|
|
824
|
+
//
|
|
825
|
+
// Our NET — the platform fee — and not the gross the buyer paid.
|
|
826
|
+
// Decided once here rather than inferred from whichever Stripe field
|
|
827
|
+
// was nearest, because the three candidates are genuinely different
|
|
828
|
+
// numbers:
|
|
829
|
+
//
|
|
830
|
+
// gross incl. tax reconciles with nothing we own
|
|
831
|
+
// GMV ex-tax what the SELLERS earned, not what we did
|
|
832
|
+
// platform net our books, our MRR, our balance ← this one
|
|
833
|
+
//
|
|
834
|
+
// This is *our* GA property and every other number in it is ours;
|
|
835
|
+
// subscription `purchase` already reports what Aglyn was paid, and
|
|
836
|
+
// marketplace revenue has to mean the same thing or the combined
|
|
837
|
+
// total, ARPA and every revenue-based audience are nonsense. The two
|
|
838
|
+
// stay separable by `item_category`.
|
|
839
|
+
//
|
|
840
|
+
// Tax is excluded rather than folded in, and is deliberately NOT sent
|
|
841
|
+
// as GA4's `tax` param either: `value` is our fee, so a `tax` beside
|
|
842
|
+
// it would not be a component of it, and asserting in GA that Aglyn
|
|
843
|
+
// took this tax is exactly the question the publisher agreement's
|
|
844
|
+
// seller-of-record clause has open. The ledger doc above keeps the
|
|
845
|
+
// full split for anyone who needs it.
|
|
846
|
+
after(()=>{
|
|
847
|
+
var _ref, _ref1;
|
|
848
|
+
var _object_metadata;
|
|
849
|
+
return sendGa4Purchase({
|
|
850
|
+
transactionId: String(object.id),
|
|
851
|
+
value: netCents / 100,
|
|
852
|
+
currency: String((_ref = object == null ? void 0 : object.currency) != null ? _ref : 'usd'),
|
|
853
|
+
items: [
|
|
854
|
+
{
|
|
855
|
+
item_id: String(listingId),
|
|
856
|
+
// The listing id, not the display name: a listing's name is
|
|
857
|
+
// seller-authored free text and is not worth risking in a
|
|
858
|
+
// dimension when the id already identifies it.
|
|
859
|
+
item_name: String(listingId),
|
|
860
|
+
item_category: 'marketplace',
|
|
861
|
+
// GA expects the items to sum to `value`; one item, one price.
|
|
862
|
+
price: netCents / 100,
|
|
863
|
+
quantity: 1
|
|
864
|
+
}
|
|
865
|
+
],
|
|
866
|
+
clientId: object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.ga_client_id,
|
|
867
|
+
stripeCustomerId: String((_ref1 = object == null ? void 0 : object.customer) != null ? _ref1 : '') || String(buyerUid)
|
|
868
|
+
}).catch(()=>undefined);
|
|
869
|
+
});
|
|
870
|
+
// A refund or a lost dispute that arrived before this document existed
|
|
871
|
+
// is applied NOW (AGL-2148) — revocation, GA and the transfer reversal,
|
|
872
|
+
// by the same function the refund door uses. Last on this path, and
|
|
873
|
+
// awaited: it can throw on a transient Stripe failure, which is exactly
|
|
874
|
+
// the redelivery this branch is already idempotent under.
|
|
875
|
+
await drainMarketplaceRefundOrphan(firebaseAdmin.app().firestore(), purchaseRef, String((_ref8 = object == null ? void 0 : object.payment_intent) != null ? _ref8 : ''));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Refund revocation (AGL-1546): a FULL refund un-buys the listing —
|
|
879
|
+
// the install gate treats a purchase with `refundedAt` as absent. Only
|
|
880
|
+
// `refunded: true` (the whole charge) revokes; a partial refund is a
|
|
881
|
+
// concession, not a revocation. Keyed by the payment intent stored at
|
|
882
|
+
// completion, and idempotent: a Stripe redelivery restamps the same
|
|
883
|
+
// values on the same doc. Requires the platform webhook endpoint to be
|
|
884
|
+
// subscribed to `charge.refunded` (AGL-1549).
|
|
885
|
+
if (type === 'charge.refunded' && (object == null ? void 0 : object.refunded) === true) {
|
|
886
|
+
var _ref9;
|
|
887
|
+
const paymentIntentId = String((_ref9 = object == null ? void 0 : object.payment_intent) != null ? _ref9 : '');
|
|
888
|
+
if (paymentIntentId) {
|
|
889
|
+
var _ref10, _ref11, _ref12, _ref13, _ref14;
|
|
890
|
+
var _object_metadata2;
|
|
891
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
892
|
+
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
893
|
+
// Everything a refund does to a purchase, and everything it needs to
|
|
894
|
+
// wait for one — assembled once so the two paths cannot describe the
|
|
895
|
+
// same refund differently.
|
|
896
|
+
const cause = {
|
|
897
|
+
kind: 'refund',
|
|
898
|
+
// `charge.refunded` carries the CHARGE, so the charge id is both the
|
|
899
|
+
// cause id and the chargeId — unchanged from AGL-1995.
|
|
900
|
+
id: String((_ref10 = object == null ? void 0 : object.id) != null ? _ref10 : ''),
|
|
901
|
+
paymentIntentId,
|
|
902
|
+
amountCents: Math.round(Number((_ref11 = object == null ? void 0 : object.amount_refunded) != null ? _ref11 : 0)),
|
|
903
|
+
chargeId: String((_ref12 = object == null ? void 0 : object.id) != null ? _ref12 : ''),
|
|
904
|
+
currency: String((_ref13 = object == null ? void 0 : object.currency) != null ? _ref13 : 'usd'),
|
|
905
|
+
stripeCustomerId: String((_ref14 = object == null ? void 0 : object.customer) != null ? _ref14 : '')
|
|
906
|
+
};
|
|
907
|
+
if (!purchases.empty) {
|
|
908
|
+
// The publisher's share comes back (AGL-1995), the entitlement is
|
|
909
|
+
// revoked (AGL-1546) and GA nets the sale out (AGL-1850) — see
|
|
910
|
+
// `applyMarketplaceRefundOutcome`. FULL refunds only, matching the
|
|
911
|
+
// revocation and GA gates: the branch is already inside
|
|
912
|
+
// `object?.refunded === true`. The seller's share of a PARTIAL refund
|
|
913
|
+
// now comes back too, without any of the other three effects — see
|
|
914
|
+
// `reverseMarketplacePartialRefundShare` below. There is no
|
|
915
|
+
// marketplace refund UI, so every refund is issued from the Stripe
|
|
916
|
+
// Dashboard and this webhook is the only code that sees one.
|
|
917
|
+
await applyMarketplaceRefundOutcome(purchases.docs[0].ref, cause);
|
|
918
|
+
} else if ((object == null ? void 0 : (_object_metadata2 = object.metadata) == null ? void 0 : _object_metadata2.type) === 'marketplace-purchase') {
|
|
919
|
+
// THE ELSE THAT DID NOT EXIST (AGL-2148). The purchase document is
|
|
920
|
+
// written by `checkout.session.completed`, that delivery retries, and
|
|
921
|
+
// a refund issued inside the window found nothing and was DROPPED.
|
|
922
|
+
//
|
|
923
|
+
// Gated on the PaymentIntent metadata stamped at marketplace checkout
|
|
924
|
+
// (checkout.ts) and copied by Stripe onto the charge, so the
|
|
925
|
+
// storefront and subscription refunds that share this endpoint park
|
|
926
|
+
// nothing. A charge predating that stamp is not parked either — its
|
|
927
|
+
// purchase document has long existed, so the join above finds it.
|
|
928
|
+
await recordRefundOrphan(firestore, cause);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// A PARTIAL refund pulls the publisher's share back too (AGL-2299) — and
|
|
933
|
+
// nothing else. The branch above is gated on `refunded === true`, which is
|
|
934
|
+
// "the whole charge is gone"; every smaller refund fell through it and the
|
|
935
|
+
// platform paid the entire concession out of a 20% cut. The entitlement is
|
|
936
|
+
// untouched on purpose: a partial refund is a concession, not an un-buy.
|
|
937
|
+
//
|
|
938
|
+
// Same marketplace discriminator the orphan store uses, for the same
|
|
939
|
+
// reason: `charge.refunded` arrives here for storefront orders and
|
|
940
|
+
// subscription charges too, and this must not read a transfer belonging to
|
|
941
|
+
// one of those.
|
|
942
|
+
//
|
|
943
|
+
// The discriminator is required only on the ORPHAN side, exactly as the
|
|
944
|
+
// full-refund branch requires it: when the payment-intent join FINDS a
|
|
945
|
+
// marketplace purchase, that join is already the proof, and demanding the
|
|
946
|
+
// metadata as well would skip every charge predating the AGL-2148 stamp.
|
|
947
|
+
if (type === 'charge.refunded' && (object == null ? void 0 : object.refunded) !== true && Math.round(Number((_ref = object == null ? void 0 : object.amount_refunded) != null ? _ref : 0)) > 0) {
|
|
948
|
+
var _ref15;
|
|
949
|
+
const paymentIntentId = String((_ref15 = object == null ? void 0 : object.payment_intent) != null ? _ref15 : '');
|
|
950
|
+
if (paymentIntentId) {
|
|
951
|
+
var _object_metadata3;
|
|
952
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
953
|
+
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
954
|
+
if (!purchases.empty) {
|
|
955
|
+
var _ref16;
|
|
956
|
+
await reverseMarketplacePartialRefundShare(purchases.docs[0].ref, String((_ref16 = object == null ? void 0 : object.id) != null ? _ref16 : ''));
|
|
957
|
+
} else if ((object == null ? void 0 : (_object_metadata3 = object.metadata) == null ? void 0 : _object_metadata3.type) === 'marketplace-purchase') {
|
|
958
|
+
var _ref17, _ref18, _ref19, _ref20, _ref21;
|
|
959
|
+
// The AGL-2148 window, for partials. Parked with the charge on it so
|
|
960
|
+
// the drain can recompute the target from Stripe rather than trusting
|
|
961
|
+
// a number this event carried; the amount rides along for forensics.
|
|
962
|
+
await recordRefundOrphan(firestore, {
|
|
963
|
+
kind: 'partial-refund',
|
|
964
|
+
id: String((_ref17 = object == null ? void 0 : object.id) != null ? _ref17 : ''),
|
|
965
|
+
paymentIntentId,
|
|
966
|
+
amountCents: Math.round(Number((_ref18 = object == null ? void 0 : object.amount_refunded) != null ? _ref18 : 0)),
|
|
967
|
+
chargeId: String((_ref19 = object == null ? void 0 : object.id) != null ? _ref19 : ''),
|
|
968
|
+
currency: String((_ref20 = object == null ? void 0 : object.currency) != null ? _ref20 : 'usd'),
|
|
969
|
+
stripeCustomerId: String((_ref21 = object == null ? void 0 : object.customer) != null ? _ref21 : '')
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Chargebacks (AGL-1554): the AGL-1546 refund arriving by the bank's
|
|
975
|
+
// door. `charge.dispute.*` for tenant storefront orders and platform
|
|
976
|
+
// subscriptions arrives on this endpoint too — the payment-intent join
|
|
977
|
+
// below simply finds no purchase for those and this section stays out of
|
|
978
|
+
// their way, exactly as the refund branch does.
|
|
979
|
+
//
|
|
980
|
+
// A dispute is NOT a refund, and the states matter (the AGL-1554
|
|
981
|
+
// analysis): `created` can still be WON, and nothing un-revokes, so it
|
|
982
|
+
// only flags the purchase for staff visibility. Money moves exclusively
|
|
983
|
+
// on `closed` + `status: 'lost'`: the buyer's entitlement goes the way
|
|
984
|
+
// the money went (`refundedAt`, the field the install gate reads as
|
|
985
|
+
// absent-purchase — final outcomes only), GA nets the AGL-1639 purchase
|
|
986
|
+
// out in the AGL-1850 accounting (platform net, guarded by the same
|
|
987
|
+
// `refundedAt` read the refund branch uses), and the seller's share
|
|
988
|
+
// comes back by the AGL-1794 policy. `won` and `warning_closed` record
|
|
989
|
+
// the outcome and move nothing.
|
|
990
|
+
if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
|
|
991
|
+
var _ref22, _ref23;
|
|
992
|
+
const disputeId = String((_ref22 = object == null ? void 0 : object.id) != null ? _ref22 : '');
|
|
993
|
+
const paymentIntentId = String((_ref23 = object == null ? void 0 : object.payment_intent) != null ? _ref23 : '');
|
|
994
|
+
// Reported to the route so it can tell "marketplace handled it" from
|
|
995
|
+
// "nothing handled it" (AGL-2429). Left false by the guard below on
|
|
996
|
+
// purpose: a dispute carrying no payment intent cannot be joined to a
|
|
997
|
+
// purchase, which is a fault, not a routine miss.
|
|
998
|
+
let claimed = false;
|
|
999
|
+
if (disputeId && paymentIntentId) {
|
|
1000
|
+
var _ref24;
|
|
1001
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
1002
|
+
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
1003
|
+
if (!purchases.empty) {
|
|
1004
|
+
var _ref25;
|
|
1005
|
+
claimed = true;
|
|
1006
|
+
const purchase = purchases.docs[0];
|
|
1007
|
+
const status = String((_ref25 = object == null ? void 0 : object.status) != null ? _ref25 : '');
|
|
1008
|
+
if (type === 'charge.dispute.created') {
|
|
1009
|
+
await purchase.ref.set({
|
|
1010
|
+
disputeId,
|
|
1011
|
+
disputeStatus: status || 'needs_response',
|
|
1012
|
+
disputeOpenedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
1013
|
+
}, {
|
|
1014
|
+
merge: true
|
|
1015
|
+
});
|
|
1016
|
+
} else if (status === 'lost') {
|
|
1017
|
+
var _ref26, _ref27, _ref28;
|
|
1018
|
+
// Revoke, report, pull the seller's share back — the same three
|
|
1019
|
+
// effects the refund door applies, through the same function
|
|
1020
|
+
// (AGL-2148), so the two doors and the orphan drain cannot drift.
|
|
1021
|
+
// The dispute outcome is stamped in the same write.
|
|
1022
|
+
await applyMarketplaceRefundOutcome(purchase.ref, {
|
|
1023
|
+
kind: 'dispute',
|
|
1024
|
+
id: disputeId,
|
|
1025
|
+
paymentIntentId,
|
|
1026
|
+
amountCents: Math.round(Number((_ref26 = object == null ? void 0 : object.amount) != null ? _ref26 : 0)),
|
|
1027
|
+
chargeId: String((_ref27 = object == null ? void 0 : object.charge) != null ? _ref27 : ''),
|
|
1028
|
+
currency: String((_ref28 = object == null ? void 0 : object.currency) != null ? _ref28 : 'usd'),
|
|
1029
|
+
// A dispute event carries no Stripe customer; the GA hit falls
|
|
1030
|
+
// back to the buyer uid on the purchase, as it always has.
|
|
1031
|
+
stripeCustomerId: ''
|
|
1032
|
+
}, {
|
|
1033
|
+
disputeId,
|
|
1034
|
+
disputeStatus: status
|
|
1035
|
+
});
|
|
1036
|
+
} else {
|
|
1037
|
+
// `won` or `warning_closed`: the money stayed, the entitlement
|
|
1038
|
+
// stays, and the outcome lands on the record for whoever flagged
|
|
1039
|
+
// it at `created`.
|
|
1040
|
+
await purchase.ref.set({
|
|
1041
|
+
disputeId,
|
|
1042
|
+
disputeStatus: status
|
|
1043
|
+
}, {
|
|
1044
|
+
merge: true
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
} else if (type === 'charge.dispute.closed' && String((_ref24 = object == null ? void 0 : object.status) != null ? _ref24 : '') === 'lost') {
|
|
1048
|
+
// The refund branch's window, by the bank's door (AGL-2148): only a
|
|
1049
|
+
// LOST closed dispute moves money, so only that state is worth
|
|
1050
|
+
// parking. A `created` landing in the same window loses nothing but
|
|
1051
|
+
// the staff flag — the `closed` that follows days later always finds
|
|
1052
|
+
// the purchase document.
|
|
1053
|
+
claimed = await recordDisputeOrphanIfMarketplace(firestore, object, paymentIntentId);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
claimed
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
//# sourceMappingURL=billing-webhook.js.map
|