@aglyn/plugins-marketplace 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +14 -14
- package/src/lib/components/listing-content.component.js +11 -3
- package/src/lib/components/listing-content.component.js.map +1 -1
- package/src/lib/components/marketplace-pay-panel.component.d.ts +67 -0
- package/src/lib/components/marketplace-pay-panel.component.js +228 -0
- package/src/lib/components/marketplace-pay-panel.component.js.map +1 -0
- package/src/lib/components/publish-artifact-dialog.component.d.ts +1 -1
- package/src/lib/components/publish-artifact-dialog.component.js +144 -20
- package/src/lib/components/publish-artifact-dialog.component.js.map +1 -1
- package/src/lib/components/publish-starter-fields.component.d.ts +35 -0
- package/src/lib/components/publish-starter-fields.component.js +176 -0
- package/src/lib/components/publish-starter-fields.component.js.map +1 -0
- package/src/lib/components/starter-picker-shelf.component.d.ts +2 -2
- package/src/lib/components/starter-picker-shelf.component.js +28 -6
- package/src/lib/components/starter-picker-shelf.component.js.map +1 -1
- package/src/lib/hooks/use-marketplace-actions.d.ts +9 -3
- package/src/lib/hooks/use-marketplace-actions.js +47 -89
- package/src/lib/hooks/use-marketplace-actions.js.map +1 -1
- package/src/lib/hooks/use-marketplace-pay.d.ts +70 -0
- package/src/lib/hooks/use-marketplace-pay.js +359 -0
- package/src/lib/hooks/use-marketplace-pay.js.map +1 -0
- package/src/lib/hooks/use-starter-publish-facts.d.ts +25 -0
- package/src/lib/hooks/use-starter-publish-facts.js +116 -0
- package/src/lib/hooks/use-starter-publish-facts.js.map +1 -0
- package/src/lib/hooks/use-starter-purchase.d.ts +17 -22
- package/src/lib/hooks/use-starter-purchase.js +26 -73
- package/src/lib/hooks/use-starter-purchase.js.map +1 -1
- package/src/lib/model/marketplace-invoice.d.ts +52 -0
- package/src/lib/model/marketplace-invoice.js +75 -0
- package/src/lib/model/marketplace-invoice.js.map +1 -0
- package/src/lib/model/marketplace-pay.d.ts +42 -0
- package/src/lib/model/marketplace-pay.js +33 -0
- package/src/lib/model/marketplace-pay.js.map +1 -0
- package/src/lib/model/platform-seller.d.ts +37 -0
- package/src/lib/model/platform-seller.js +36 -0
- package/src/lib/model/platform-seller.js.map +1 -0
- package/src/lib/model/publisher-agreement.d.ts +15 -3
- package/src/lib/model/publisher-agreement.js +23 -3
- package/src/lib/model/publisher-agreement.js.map +1 -1
- package/src/lib/model/starter-publish.d.ts +159 -0
- package/src/lib/model/starter-publish.js +189 -0
- package/src/lib/model/starter-publish.js.map +1 -0
- package/src/lib/model/starter-query.d.ts +0 -8
- package/src/lib/model/starter-query.js +0 -10
- package/src/lib/model/starter-query.js.map +1 -1
- package/src/lib/server/billing-webhook.d.ts +6 -0
- package/src/lib/server/billing-webhook.js +269 -211
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/checkout.d.ts +2 -48
- package/src/lib/server/checkout.js +21 -241
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/pay.d.ts +18 -0
- package/src/lib/server/pay.js +540 -0
- package/src/lib/server/pay.js.map +1 -0
- package/src/lib/server/platform-org.d.ts +23 -0
- package/src/lib/server/platform-org.js +27 -0
- package/src/lib/server/platform-org.js.map +1 -0
- package/src/lib/server/publish-preconditions.js +4 -1
- package/src/lib/server/publish-preconditions.js.map +1 -1
- package/src/lib/server/publish-starter.js +5 -2
- package/src/lib/server/publish-starter.js.map +1 -1
- package/src/lib/server/publisher-profile.d.ts +5 -0
- package/src/lib/server/publisher-profile.js +20 -1
- package/src/lib/server/publisher-profile.js.map +1 -1
- package/src/lib/server/publisher-standing.d.ts +26 -0
- package/src/lib/server/publisher-standing.js +66 -0
- package/src/lib/server/publisher-standing.js.map +1 -0
- package/src/lib/server/sale.d.ts +119 -0
- package/src/lib/server/sale.js +304 -0
- package/src/lib/server/sale.js.map +1 -0
- package/src/lib/server/starter-publish-facts.d.ts +36 -0
- package/src/lib/server/starter-publish-facts.js +135 -0
- package/src/lib/server/starter-publish-facts.js.map +1 -0
- package/src/lib/server.js +8 -0
- package/src/lib/server.js.map +1 -1
|
@@ -27,6 +27,7 @@ import { paymentRiskEventFrom } from "@aglyn/aglyn/app-utils/payment-risk";
|
|
|
27
27
|
import { recordPaymentRiskOnRecord } from "@aglyn/tenant-data-admin/server/payment-risk-record";
|
|
28
28
|
import { firebaseAdmin, notifyRiskEvent, sendGa4Purchase, sendGa4Refund } from "@aglyn/tenant-data-admin";
|
|
29
29
|
import { paymentProvider } from "@aglyn/tenant-data-admin/server/payment-provider";
|
|
30
|
+
import { invoicePaymentIntentId, invoiceTaxCents, isMarketplaceInvoice } from "../model/marketplace-invoice.js";
|
|
30
31
|
import { screenMarketplaceSale } from "./sale-risk.js";
|
|
31
32
|
/** Stripe failures a redelivery can actually fix. */ function isTransientStripeStatus(status) {
|
|
32
33
|
return status === 429 || status >= 500;
|
|
@@ -54,9 +55,7 @@ import { screenMarketplaceSale } from "./sale-risk.js";
|
|
|
54
55
|
* Null when the session states no country. An unstated jurisdiction is COUNTED
|
|
55
56
|
* by the return (`rowsMissingJurisdiction`) and never inferred: a jurisdiction
|
|
56
57
|
* reconstructed after the fact is a guess handed to a tax authority as a fact.
|
|
57
|
-
*/ function buyerTaxJurisdiction(
|
|
58
|
-
var _object_customer_details;
|
|
59
|
-
const address = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.address;
|
|
58
|
+
*/ function buyerTaxJurisdiction(address) {
|
|
60
59
|
const country = typeof (address == null ? void 0 : address.country) === 'string' ? address.country.trim() : '';
|
|
61
60
|
if (!country) return null;
|
|
62
61
|
const state = typeof (address == null ? void 0 : address.state) === 'string' ? address.state.trim() : '';
|
|
@@ -93,6 +92,17 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
93
92
|
if (snapshot.get('reversedTransferCents') != null) return;
|
|
94
93
|
const causeAmountCents = Math.round(Number((_ref1 = cause == null ? void 0 : cause.amountCents) != null ? _ref1 : 0));
|
|
95
94
|
if (!(causeAmountCents > 0)) return;
|
|
95
|
+
// NOBODY WAS PAID (AGL-4049): a sale by the platform's own org. There is no
|
|
96
|
+
// transfer to pull back, and that is the sale working, so it settles here
|
|
97
|
+
// rather than landing in the recovery queue as a share that was lost.
|
|
98
|
+
if (snapshot.get('transferCents') === 0) {
|
|
99
|
+
await purchaseRef.set({
|
|
100
|
+
reversedTransferCents: 0
|
|
101
|
+
}, {
|
|
102
|
+
merge: true
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
96
106
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
97
107
|
if (!stripeKey) {
|
|
98
108
|
console.error('Marketplace transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-1554)');
|
|
@@ -340,8 +350,17 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
340
350
|
if (!(chargeAmountCents > 0) || !(refundedCents > 0)) return;
|
|
341
351
|
const transferId = String((_ref2 = (_charge_body2 = charge.body) == null ? void 0 : _charge_body2.transfer) != null ? _ref2 : '');
|
|
342
352
|
if (!transferId) {
|
|
343
|
-
// No destination transfer means no seller share to pull back
|
|
353
|
+
// No destination transfer means no seller share to pull back. A sale by
|
|
354
|
+
// the platform's own org has none by design (AGL-4049), and what the
|
|
355
|
+
// buyer had back is still recorded for the revenue report; any other
|
|
344
356
|
// marketplace charge always has one, so this is a charge we do not own.
|
|
357
|
+
if (snapshot.get('transferCents') === 0) {
|
|
358
|
+
await purchaseRef.set({
|
|
359
|
+
partialRefundedCents: refundedCents
|
|
360
|
+
}, {
|
|
361
|
+
merge: true
|
|
362
|
+
});
|
|
363
|
+
}
|
|
345
364
|
return;
|
|
346
365
|
}
|
|
347
366
|
const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${transferId}`, stripeKey);
|
|
@@ -675,6 +694,200 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
675
694
|
// exactly what the route's unattributed alert exists to catch.
|
|
676
695
|
return true;
|
|
677
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* Writes one sale's purchase document, the buyer's entitlement, the seller
|
|
699
|
+
* ledger and the tax row, and everything that follows it: the GA hit, the
|
|
700
|
+
* sale's fraud screen and the drain of any refund that got here first.
|
|
701
|
+
*
|
|
702
|
+
* ONE function for both doors (AGL-4049), so a sale paid in the console and one
|
|
703
|
+
* paid at the hosted checkout are the same document, and `hasLivePurchase`,
|
|
704
|
+
* the seller ledger and the refund doors cannot tell them apart. Keyed by the
|
|
705
|
+
* object that paid, and idempotent: a redelivery changes nothing.
|
|
706
|
+
*/ async function recordMarketplacePurchase(sale) {
|
|
707
|
+
const { recordId, metadata, grossCents, taxCents, jurisdiction, paymentIntentId, currency, stripeCustomerId, livemode } = sale;
|
|
708
|
+
// Sellers are orgs (AGL-652) — the ledger records which ORG earned it.
|
|
709
|
+
const { listingId, buyerUid, buyerOrgId, sellerOrgId, feeCents, transferCents } = metadata;
|
|
710
|
+
if (listingId && buyerUid && sellerOrgId) {
|
|
711
|
+
// The remittance-correct split (AGL-1544), read ONCE and used by both
|
|
712
|
+
// the ledger and the GA hit (AGL-1639) — the two must not be able to
|
|
713
|
+
// describe the same sale differently.
|
|
714
|
+
//
|
|
715
|
+
// `amount_total` is the tax-inclusive GROSS the buyer paid. Out of it:
|
|
716
|
+
// `taxCents` is what the PLATFORM owes the state (collected under the
|
|
717
|
+
// marketplace-provider registration, never ours), and `sellerCents` is
|
|
718
|
+
// the fixed transfer the seller's Connect account received (their
|
|
719
|
+
// share of the pre-tax price). What is left is what Aglyn keeps.
|
|
720
|
+
const sellerCents = Number(transferCents != null ? transferCents : 0);
|
|
721
|
+
const netCents = grossCents - taxCents - sellerCents;
|
|
722
|
+
const purchaseRef = firebaseAdmin.app().firestore().collection('marketplacePurchases').doc(recordId);
|
|
723
|
+
// `createdAt` is stamped ONCE (AGL-2109). Merging the write below fixed
|
|
724
|
+
// the erasure but introduced its own drift: `serverTimestamp()` on a
|
|
725
|
+
// redelivery would move a three-day-old sale to today, which is the
|
|
726
|
+
// field the seller ledger and every revenue period read. One read, on a
|
|
727
|
+
// path that already makes several Stripe round trips, buys a date that
|
|
728
|
+
// means what it says.
|
|
729
|
+
const alreadyRecorded = (await purchaseRef.get()).exists;
|
|
730
|
+
await purchaseRef.set(_extends({
|
|
731
|
+
listingId,
|
|
732
|
+
buyerUid
|
|
733
|
+
}, buyerOrgId ? {
|
|
734
|
+
buyerOrgId: String(buyerOrgId)
|
|
735
|
+
} : {}, {
|
|
736
|
+
sellerOrgId,
|
|
737
|
+
// Gross − tax − transfer = the platform fee, which feeCents also
|
|
738
|
+
// records independently from the rate resolved at checkout.
|
|
739
|
+
amountCents: grossCents,
|
|
740
|
+
feeCents: Number(feeCents != null ? feeCents : 0),
|
|
741
|
+
taxCents,
|
|
742
|
+
transferCents: sellerCents,
|
|
743
|
+
// The refund trail (AGL-1546): `charge.refunded` carries the
|
|
744
|
+
// payment intent, not the session — without this id a refund
|
|
745
|
+
// could never find the purchase it revokes.
|
|
746
|
+
paymentIntentId
|
|
747
|
+
}, alreadyRecorded ? {} : _extends({
|
|
748
|
+
createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
749
|
+
}, jurisdiction ? {
|
|
750
|
+
customerAddress: jurisdiction
|
|
751
|
+
} : {}, {
|
|
752
|
+
// WHAT THE LICENSES TAB QUERIES BY (AGL-3321), explicit from
|
|
753
|
+
// the first record. A query cannot ask for an ABSENT field,
|
|
754
|
+
// so "not refunded" is `refundedAt == null` and "licenses no
|
|
755
|
+
// workspace in particular" is `buyerOrgId == null` — both
|
|
756
|
+
// only reach a document that carries the null. Here, in the
|
|
757
|
+
// first-record branch, and nowhere else: a redelivery landing
|
|
758
|
+
// after a refund must never write `refundedAt` back to null,
|
|
759
|
+
// which would hand the refunded buyer their install back.
|
|
760
|
+
refundedAt: null
|
|
761
|
+
}, buyerOrgId ? {} : {
|
|
762
|
+
buyerOrgId: null
|
|
763
|
+
})), // MERGED, like every other write on this path (AGL-2109). This one
|
|
764
|
+
// was a full document REPLACE, and the document it replaces is not
|
|
765
|
+
// only the sale: it is the buyer's ENTITLEMENT (`hasLivePurchase`
|
|
766
|
+
// reads `refundedAt`), the refund trail, the dispute trail, and the
|
|
767
|
+
// transfer-reversal SETTLE MARKER (`reversedTransferCents`, which
|
|
768
|
+
// `reverseMarketplaceSellerShare` short-circuits on so a publisher is
|
|
769
|
+
// never debited twice).
|
|
770
|
+
//
|
|
771
|
+
// Stripe redelivers `checkout.session.completed` for up to three days
|
|
772
|
+
// after any 500, and this endpoint 500s on purpose — for every
|
|
773
|
+
// transient Stripe failure raised in this file and for any throw from
|
|
774
|
+
// a sibling plugin handler that runs after this one
|
|
775
|
+
// (`runBillingWebhookHandlers` awaits them in sequence and lets a
|
|
776
|
+
// throw propagate). So the redelivery is expected traffic, not an
|
|
777
|
+
// exotic race. Landing after a refund it erased `refundedAt` — the
|
|
778
|
+
// refunded buyer silently got their install back — and erased
|
|
779
|
+
// `reversedTransferCents`, re-opening a second reversal against the
|
|
780
|
+
// publisher's Connect account.
|
|
781
|
+
//
|
|
782
|
+
// Merging is safe in the other direction too: every field written
|
|
783
|
+
// here is derived from the session event, so a redelivery restamps
|
|
784
|
+
// identical values.
|
|
785
|
+
{
|
|
786
|
+
merge: true
|
|
787
|
+
});
|
|
788
|
+
// Marketplace sales are real revenue and belong in the same GA
|
|
789
|
+
// `purchase` stream as subscriptions (AGL-1561), separated by
|
|
790
|
+
// `item_category` so plugin revenue and subscription revenue can be
|
|
791
|
+
// read apart or together.
|
|
792
|
+
//
|
|
793
|
+
// After the ledger write, and fire-and-forget: the ledger is what
|
|
794
|
+
// grants the install entitlement, and an analytics failure must never
|
|
795
|
+
// throw here — the route deletes its idempotency claim on any throw,
|
|
796
|
+
// which would make Stripe redeliver a purchase that already landed.
|
|
797
|
+
//
|
|
798
|
+
// `transaction_id` is the checkout session id — the same key the
|
|
799
|
+
// ledger doc uses — so GA de-duplicates a redelivery exactly as
|
|
800
|
+
// Firestore does.
|
|
801
|
+
// WHAT COUNTS AS REVENUE ON A MARKETPLACE SALE (AGL-1639)
|
|
802
|
+
//
|
|
803
|
+
// Our NET — the platform fee — and not the gross the buyer paid.
|
|
804
|
+
// Decided once here rather than inferred from whichever Stripe field
|
|
805
|
+
// was nearest, because the three candidates are genuinely different
|
|
806
|
+
// numbers:
|
|
807
|
+
//
|
|
808
|
+
// gross incl. tax reconciles with nothing we own
|
|
809
|
+
// GMV ex-tax what the SELLERS earned, not what we did
|
|
810
|
+
// platform net our books, our MRR, our balance ← this one
|
|
811
|
+
//
|
|
812
|
+
// This is *our* GA property and every other number in it is ours;
|
|
813
|
+
// subscription `purchase` already reports what Aglyn was paid, and
|
|
814
|
+
// marketplace revenue has to mean the same thing or the combined
|
|
815
|
+
// total, ARPA and every revenue-based audience are nonsense. The two
|
|
816
|
+
// stay separable by `item_category`.
|
|
817
|
+
//
|
|
818
|
+
// Tax is excluded rather than folded in, and is deliberately NOT sent
|
|
819
|
+
// as GA4's `tax` param either: `value` is our fee, so a `tax` beside
|
|
820
|
+
// it would not be a component of it, and asserting in GA that Aglyn
|
|
821
|
+
// took this tax is exactly the question the publisher agreement's
|
|
822
|
+
// seller-of-record clause has open. The ledger doc above keeps the
|
|
823
|
+
// full split for anyone who needs it.
|
|
824
|
+
after(()=>sendGa4Purchase({
|
|
825
|
+
transactionId: recordId,
|
|
826
|
+
value: netCents / 100,
|
|
827
|
+
currency,
|
|
828
|
+
items: [
|
|
829
|
+
{
|
|
830
|
+
item_id: String(listingId),
|
|
831
|
+
// The listing id, not the display name: a listing's name is
|
|
832
|
+
// seller-authored free text and is not worth risking in a
|
|
833
|
+
// dimension when the id already identifies it.
|
|
834
|
+
item_name: String(listingId),
|
|
835
|
+
item_category: 'marketplace',
|
|
836
|
+
// GA expects the items to sum to `value`; one item, one price.
|
|
837
|
+
price: netCents / 100,
|
|
838
|
+
quantity: 1
|
|
839
|
+
}
|
|
840
|
+
],
|
|
841
|
+
clientId: metadata['ga_client_id'],
|
|
842
|
+
stripeCustomerId: stripeCustomerId || String(buyerUid)
|
|
843
|
+
}).catch(()=>undefined));
|
|
844
|
+
// The sale's fraud shape (AGL-3365): the publisher paying itself, one
|
|
845
|
+
// card buying for several workspaces, a brand-new publisher's large
|
|
846
|
+
// sale — and a young publisher's payouts held. Once per sale, after the
|
|
847
|
+
// response like the GA hit: it reads and files, and the sale it reads
|
|
848
|
+
// is already recorded. `screenMarketplaceSale` never throws.
|
|
849
|
+
if (!alreadyRecorded) {
|
|
850
|
+
after(()=>screenMarketplaceSale({
|
|
851
|
+
purchaseRef,
|
|
852
|
+
sessionId: recordId,
|
|
853
|
+
buyerUid: String(buyerUid),
|
|
854
|
+
buyerOrgId: buyerOrgId ? String(buyerOrgId) : null,
|
|
855
|
+
sellerOrgId: String(sellerOrgId),
|
|
856
|
+
amountCents: grossCents - taxCents,
|
|
857
|
+
paymentIntentId,
|
|
858
|
+
livemode
|
|
859
|
+
}, {
|
|
860
|
+
stripeKey: process.env.STRIPE_SECRET_KEY,
|
|
861
|
+
notifyRisk: notifyRiskEvent
|
|
862
|
+
}).catch(()=>undefined));
|
|
863
|
+
}
|
|
864
|
+
// A refund or a lost dispute that arrived before this document existed
|
|
865
|
+
// is applied NOW (AGL-2148) — revocation, GA and the transfer reversal,
|
|
866
|
+
// by the same function the refund door uses. Last on this path, and
|
|
867
|
+
// awaited: it can throw on a transient Stripe failure, which is exactly
|
|
868
|
+
// the redelivery this branch is already idempotent under.
|
|
869
|
+
await drainMarketplaceRefundOrphan(firebaseAdmin.app().firestore(), purchaseRef, paymentIntentId);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* A paid marketplace invoice, recorded (AGL-4049). `amount_paid` is the
|
|
874
|
+
* tax-inclusive gross, as a session's `amount_total` is, and `customer_address`
|
|
875
|
+
* is the address Stripe computed the tax from, frozen at finalize.
|
|
876
|
+
*/ export async function recordMarketplaceInvoicePurchase(invoice) {
|
|
877
|
+
var _ref, _ref1, _ref2, _ref3, _ref4;
|
|
878
|
+
const customer = invoice == null ? void 0 : invoice.customer;
|
|
879
|
+
await recordMarketplacePurchase({
|
|
880
|
+
recordId: String((_ref = invoice == null ? void 0 : invoice.id) != null ? _ref : ''),
|
|
881
|
+
metadata: (_ref1 = invoice == null ? void 0 : invoice.metadata) != null ? _ref1 : {},
|
|
882
|
+
grossCents: Number((_ref2 = invoice == null ? void 0 : invoice.amount_paid) != null ? _ref2 : 0),
|
|
883
|
+
taxCents: invoiceTaxCents(invoice),
|
|
884
|
+
jurisdiction: buyerTaxJurisdiction(invoice == null ? void 0 : invoice.customer_address),
|
|
885
|
+
paymentIntentId: invoicePaymentIntentId(invoice),
|
|
886
|
+
currency: String((_ref3 = invoice == null ? void 0 : invoice.currency) != null ? _ref3 : 'usd'),
|
|
887
|
+
stripeCustomerId: String(typeof customer === 'string' ? customer : (_ref4 = customer == null ? void 0 : customer.id) != null ? _ref4 : ''),
|
|
888
|
+
livemode: (invoice == null ? void 0 : invoice.livemode) === true
|
|
889
|
+
});
|
|
890
|
+
}
|
|
678
891
|
/**
|
|
679
892
|
* Marketplace-purchase section of the platform Stripe webhook (AGL-46/418):
|
|
680
893
|
* keyed by session id (idempotent on Stripe redelivery) — relocated
|
|
@@ -744,184 +957,29 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
744
957
|
// Marketplace purchases (AGL-46): keyed by session id (idempotent on
|
|
745
958
|
// Stripe redelivery). Install gating and the seller ledger read these.
|
|
746
959
|
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') {
|
|
747
|
-
var _object_metadata1;
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
// `createdAt` is stamped ONCE (AGL-2109). Merging the write below fixed
|
|
771
|
-
// the erasure but introduced its own drift: `serverTimestamp()` on a
|
|
772
|
-
// redelivery would move a three-day-old sale to today, which is the
|
|
773
|
-
// field the seller ledger and every revenue period read. One read, on a
|
|
774
|
-
// path that already makes several Stripe round trips, buys a date that
|
|
775
|
-
// means what it says.
|
|
776
|
-
const alreadyRecorded = (await purchaseRef.get()).exists;
|
|
777
|
-
await purchaseRef.set(_extends({
|
|
778
|
-
listingId,
|
|
779
|
-
buyerUid
|
|
780
|
-
}, buyerOrgId ? {
|
|
781
|
-
buyerOrgId: String(buyerOrgId)
|
|
782
|
-
} : {}, {
|
|
783
|
-
sellerOrgId,
|
|
784
|
-
// Gross − tax − transfer = the platform fee, which feeCents also
|
|
785
|
-
// records independently from the rate resolved at checkout.
|
|
786
|
-
amountCents: grossCents,
|
|
787
|
-
feeCents: Number(feeCents != null ? feeCents : 0),
|
|
788
|
-
taxCents,
|
|
789
|
-
transferCents: sellerCents,
|
|
790
|
-
// The refund trail (AGL-1546): `charge.refunded` carries the
|
|
791
|
-
// payment intent, not the session — without this id a refund
|
|
792
|
-
// could never find the purchase it revokes.
|
|
793
|
-
paymentIntentId: String((_ref3 = object == null ? void 0 : object.payment_intent) != null ? _ref3 : '')
|
|
794
|
-
}, alreadyRecorded ? {} : _extends({
|
|
795
|
-
createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
796
|
-
}, jurisdiction ? {
|
|
797
|
-
customerAddress: jurisdiction
|
|
798
|
-
} : {}, {
|
|
799
|
-
// WHAT THE LICENSES TAB QUERIES BY (AGL-3321), explicit from
|
|
800
|
-
// the first record. A query cannot ask for an ABSENT field,
|
|
801
|
-
// so "not refunded" is `refundedAt == null` and "licenses no
|
|
802
|
-
// workspace in particular" is `buyerOrgId == null` — both
|
|
803
|
-
// only reach a document that carries the null. Here, in the
|
|
804
|
-
// first-record branch, and nowhere else: a redelivery landing
|
|
805
|
-
// after a refund must never write `refundedAt` back to null,
|
|
806
|
-
// which would hand the refunded buyer their install back.
|
|
807
|
-
refundedAt: null
|
|
808
|
-
}, buyerOrgId ? {} : {
|
|
809
|
-
buyerOrgId: null
|
|
810
|
-
})), // MERGED, like every other write on this path (AGL-2109). This one
|
|
811
|
-
// was a full document REPLACE, and the document it replaces is not
|
|
812
|
-
// only the sale: it is the buyer's ENTITLEMENT (`hasLivePurchase`
|
|
813
|
-
// reads `refundedAt`), the refund trail, the dispute trail, and the
|
|
814
|
-
// transfer-reversal SETTLE MARKER (`reversedTransferCents`, which
|
|
815
|
-
// `reverseMarketplaceSellerShare` short-circuits on so a publisher is
|
|
816
|
-
// never debited twice).
|
|
817
|
-
//
|
|
818
|
-
// Stripe redelivers `checkout.session.completed` for up to three days
|
|
819
|
-
// after any 500, and this endpoint 500s on purpose — for every
|
|
820
|
-
// transient Stripe failure raised in this file and for any throw from
|
|
821
|
-
// a sibling plugin handler that runs after this one
|
|
822
|
-
// (`runBillingWebhookHandlers` awaits them in sequence and lets a
|
|
823
|
-
// throw propagate). So the redelivery is expected traffic, not an
|
|
824
|
-
// exotic race. Landing after a refund it erased `refundedAt` — the
|
|
825
|
-
// refunded buyer silently got their install back — and erased
|
|
826
|
-
// `reversedTransferCents`, re-opening a second reversal against the
|
|
827
|
-
// publisher's Connect account.
|
|
828
|
-
//
|
|
829
|
-
// Merging is safe in the other direction too: every field written
|
|
830
|
-
// here is derived from the session event, so a redelivery restamps
|
|
831
|
-
// identical values.
|
|
832
|
-
{
|
|
833
|
-
merge: true
|
|
834
|
-
});
|
|
835
|
-
// Marketplace sales are real revenue and belong in the same GA
|
|
836
|
-
// `purchase` stream as subscriptions (AGL-1561), separated by
|
|
837
|
-
// `item_category` so plugin revenue and subscription revenue can be
|
|
838
|
-
// read apart or together.
|
|
839
|
-
//
|
|
840
|
-
// After the ledger write, and fire-and-forget: the ledger is what
|
|
841
|
-
// grants the install entitlement, and an analytics failure must never
|
|
842
|
-
// throw here — the route deletes its idempotency claim on any throw,
|
|
843
|
-
// which would make Stripe redeliver a purchase that already landed.
|
|
844
|
-
//
|
|
845
|
-
// `transaction_id` is the checkout session id — the same key the
|
|
846
|
-
// ledger doc uses — so GA de-duplicates a redelivery exactly as
|
|
847
|
-
// Firestore does.
|
|
848
|
-
// WHAT COUNTS AS REVENUE ON A MARKETPLACE SALE (AGL-1639)
|
|
849
|
-
//
|
|
850
|
-
// Our NET — the platform fee — and not the gross the buyer paid.
|
|
851
|
-
// Decided once here rather than inferred from whichever Stripe field
|
|
852
|
-
// was nearest, because the three candidates are genuinely different
|
|
853
|
-
// numbers:
|
|
854
|
-
//
|
|
855
|
-
// gross incl. tax reconciles with nothing we own
|
|
856
|
-
// GMV ex-tax what the SELLERS earned, not what we did
|
|
857
|
-
// platform net our books, our MRR, our balance ← this one
|
|
858
|
-
//
|
|
859
|
-
// This is *our* GA property and every other number in it is ours;
|
|
860
|
-
// subscription `purchase` already reports what Aglyn was paid, and
|
|
861
|
-
// marketplace revenue has to mean the same thing or the combined
|
|
862
|
-
// total, ARPA and every revenue-based audience are nonsense. The two
|
|
863
|
-
// stay separable by `item_category`.
|
|
864
|
-
//
|
|
865
|
-
// Tax is excluded rather than folded in, and is deliberately NOT sent
|
|
866
|
-
// as GA4's `tax` param either: `value` is our fee, so a `tax` beside
|
|
867
|
-
// it would not be a component of it, and asserting in GA that Aglyn
|
|
868
|
-
// took this tax is exactly the question the publisher agreement's
|
|
869
|
-
// seller-of-record clause has open. The ledger doc above keeps the
|
|
870
|
-
// full split for anyone who needs it.
|
|
871
|
-
after(()=>{
|
|
872
|
-
var _ref, _ref1;
|
|
873
|
-
var _object_metadata;
|
|
874
|
-
return sendGa4Purchase({
|
|
875
|
-
transactionId: String(object.id),
|
|
876
|
-
value: netCents / 100,
|
|
877
|
-
currency: String((_ref = object == null ? void 0 : object.currency) != null ? _ref : 'usd'),
|
|
878
|
-
items: [
|
|
879
|
-
{
|
|
880
|
-
item_id: String(listingId),
|
|
881
|
-
// The listing id, not the display name: a listing's name is
|
|
882
|
-
// seller-authored free text and is not worth risking in a
|
|
883
|
-
// dimension when the id already identifies it.
|
|
884
|
-
item_name: String(listingId),
|
|
885
|
-
item_category: 'marketplace',
|
|
886
|
-
// GA expects the items to sum to `value`; one item, one price.
|
|
887
|
-
price: netCents / 100,
|
|
888
|
-
quantity: 1
|
|
889
|
-
}
|
|
890
|
-
],
|
|
891
|
-
clientId: object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.ga_client_id,
|
|
892
|
-
stripeCustomerId: String((_ref1 = object == null ? void 0 : object.customer) != null ? _ref1 : '') || String(buyerUid)
|
|
893
|
-
}).catch(()=>undefined);
|
|
894
|
-
});
|
|
895
|
-
// The sale's fraud shape (AGL-3365): the publisher paying itself, one
|
|
896
|
-
// card buying for several workspaces, a brand-new publisher's large
|
|
897
|
-
// sale — and a young publisher's payouts held. Once per sale, after the
|
|
898
|
-
// response like the GA hit: it reads and files, and the sale it reads
|
|
899
|
-
// is already recorded. `screenMarketplaceSale` never throws.
|
|
900
|
-
if (!alreadyRecorded) {
|
|
901
|
-
after(()=>{
|
|
902
|
-
var _ref;
|
|
903
|
-
return screenMarketplaceSale({
|
|
904
|
-
purchaseRef,
|
|
905
|
-
sessionId: String(object.id),
|
|
906
|
-
buyerUid: String(buyerUid),
|
|
907
|
-
buyerOrgId: buyerOrgId ? String(buyerOrgId) : null,
|
|
908
|
-
sellerOrgId: String(sellerOrgId),
|
|
909
|
-
amountCents: grossCents - taxCents,
|
|
910
|
-
paymentIntentId: String((_ref = object == null ? void 0 : object.payment_intent) != null ? _ref : ''),
|
|
911
|
-
livemode: (event == null ? void 0 : event.livemode) === true
|
|
912
|
-
}, {
|
|
913
|
-
stripeKey: process.env.STRIPE_SECRET_KEY,
|
|
914
|
-
notifyRisk: notifyRiskEvent
|
|
915
|
-
}).catch(()=>undefined);
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
// A refund or a lost dispute that arrived before this document existed
|
|
919
|
-
// is applied NOW (AGL-2148) — revocation, GA and the transfer reversal,
|
|
920
|
-
// by the same function the refund door uses. Last on this path, and
|
|
921
|
-
// awaited: it can throw on a transient Stripe failure, which is exactly
|
|
922
|
-
// the redelivery this branch is already idempotent under.
|
|
923
|
-
await drainMarketplaceRefundOrphan(firebaseAdmin.app().firestore(), purchaseRef, String((_ref4 = object == null ? void 0 : object.payment_intent) != null ? _ref4 : ''));
|
|
924
|
-
}
|
|
960
|
+
var _object_metadata1, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
961
|
+
var _object_total_details, _object_customer_details;
|
|
962
|
+
// `amount_total` is the tax-inclusive GROSS the buyer paid. WHERE the tax
|
|
963
|
+
// is owed is read from the session Stripe computed it from — see
|
|
964
|
+
// `buyerTaxJurisdiction`.
|
|
965
|
+
await recordMarketplacePurchase({
|
|
966
|
+
recordId: String(object.id),
|
|
967
|
+
metadata: (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {},
|
|
968
|
+
grossCents: Number((_ref1 = object == null ? void 0 : object.amount_total) != null ? _ref1 : 0),
|
|
969
|
+
taxCents: Number((_ref2 = object == null ? void 0 : (_object_total_details = object.total_details) == null ? void 0 : _object_total_details.amount_tax) != null ? _ref2 : 0),
|
|
970
|
+
jurisdiction: buyerTaxJurisdiction(object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.address),
|
|
971
|
+
paymentIntentId: String((_ref3 = object == null ? void 0 : object.payment_intent) != null ? _ref3 : ''),
|
|
972
|
+
currency: String((_ref4 = object == null ? void 0 : object.currency) != null ? _ref4 : 'usd'),
|
|
973
|
+
stripeCustomerId: String((_ref5 = object == null ? void 0 : object.customer) != null ? _ref5 : ''),
|
|
974
|
+
livemode: (event == null ? void 0 : event.livemode) === true
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
// The same purchase, paid in the console (AGL-4049). `pay.ts` has usually
|
|
978
|
+
// written it already; this is the record of every other way the invoice
|
|
979
|
+
// gets paid (the bank's step finishing after the tab closed, Billing's own
|
|
980
|
+
// Pay button), and it lands on the same document.
|
|
981
|
+
if (type === 'invoice.paid' && isMarketplaceInvoice(object)) {
|
|
982
|
+
await recordMarketplaceInvoicePurchase(object);
|
|
925
983
|
}
|
|
926
984
|
// Refund revocation (AGL-1546): a FULL refund un-buys the listing —
|
|
927
985
|
// the install gate treats a purchase with `refundedAt` as absent. Only
|
|
@@ -931,10 +989,10 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
931
989
|
// values on the same doc. Requires the platform webhook endpoint to be
|
|
932
990
|
// subscribed to `charge.refunded` (AGL-1549).
|
|
933
991
|
if (type === 'charge.refunded' && (object == null ? void 0 : object.refunded) === true) {
|
|
934
|
-
var
|
|
935
|
-
const paymentIntentId = String((
|
|
992
|
+
var _ref6;
|
|
993
|
+
const paymentIntentId = String((_ref6 = object == null ? void 0 : object.payment_intent) != null ? _ref6 : '');
|
|
936
994
|
if (paymentIntentId) {
|
|
937
|
-
var
|
|
995
|
+
var _ref7, _ref8, _ref9, _ref10, _ref11;
|
|
938
996
|
var _object_metadata2;
|
|
939
997
|
const firestore = firebaseAdmin.app().firestore();
|
|
940
998
|
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
@@ -945,12 +1003,12 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
945
1003
|
kind: 'refund',
|
|
946
1004
|
// `charge.refunded` carries the CHARGE, so the charge id is both the
|
|
947
1005
|
// cause id and the chargeId — unchanged from AGL-1995.
|
|
948
|
-
id: String((
|
|
1006
|
+
id: String((_ref7 = object == null ? void 0 : object.id) != null ? _ref7 : ''),
|
|
949
1007
|
paymentIntentId,
|
|
950
|
-
amountCents: Math.round(Number((
|
|
951
|
-
chargeId: String((
|
|
952
|
-
currency: String((
|
|
953
|
-
stripeCustomerId: String((
|
|
1008
|
+
amountCents: Math.round(Number((_ref8 = object == null ? void 0 : object.amount_refunded) != null ? _ref8 : 0)),
|
|
1009
|
+
chargeId: String((_ref9 = object == null ? void 0 : object.id) != null ? _ref9 : ''),
|
|
1010
|
+
currency: String((_ref10 = object == null ? void 0 : object.currency) != null ? _ref10 : 'usd'),
|
|
1011
|
+
stripeCustomerId: String((_ref11 = object == null ? void 0 : object.customer) != null ? _ref11 : '')
|
|
954
1012
|
};
|
|
955
1013
|
if (!purchases.empty) {
|
|
956
1014
|
// The publisher's share comes back (AGL-1995), the entitlement is
|
|
@@ -993,28 +1051,28 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
993
1051
|
// marketplace purchase, that join is already the proof, and demanding the
|
|
994
1052
|
// metadata as well would skip every charge predating the AGL-2148 stamp.
|
|
995
1053
|
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) {
|
|
996
|
-
var
|
|
997
|
-
const paymentIntentId = String((
|
|
1054
|
+
var _ref12;
|
|
1055
|
+
const paymentIntentId = String((_ref12 = object == null ? void 0 : object.payment_intent) != null ? _ref12 : '');
|
|
998
1056
|
if (paymentIntentId) {
|
|
999
1057
|
var _object_metadata3;
|
|
1000
1058
|
const firestore = firebaseAdmin.app().firestore();
|
|
1001
1059
|
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
1002
1060
|
if (!purchases.empty) {
|
|
1003
|
-
var
|
|
1004
|
-
await reverseMarketplacePartialRefundShare(purchases.docs[0].ref, String((
|
|
1061
|
+
var _ref13;
|
|
1062
|
+
await reverseMarketplacePartialRefundShare(purchases.docs[0].ref, String((_ref13 = object == null ? void 0 : object.id) != null ? _ref13 : ''));
|
|
1005
1063
|
} else if ((object == null ? void 0 : (_object_metadata3 = object.metadata) == null ? void 0 : _object_metadata3.type) === 'marketplace-purchase') {
|
|
1006
|
-
var
|
|
1064
|
+
var _ref14, _ref15, _ref16, _ref17, _ref18;
|
|
1007
1065
|
// The AGL-2148 window, for partials. Parked with the charge on it so
|
|
1008
1066
|
// the drain can recompute the target from Stripe rather than trusting
|
|
1009
1067
|
// a number this event carried; the amount rides along for forensics.
|
|
1010
1068
|
await recordRefundOrphan(firestore, {
|
|
1011
1069
|
kind: 'partial-refund',
|
|
1012
|
-
id: String((
|
|
1070
|
+
id: String((_ref14 = object == null ? void 0 : object.id) != null ? _ref14 : ''),
|
|
1013
1071
|
paymentIntentId,
|
|
1014
|
-
amountCents: Math.round(Number((
|
|
1015
|
-
chargeId: String((
|
|
1016
|
-
currency: String((
|
|
1017
|
-
stripeCustomerId: String((
|
|
1072
|
+
amountCents: Math.round(Number((_ref15 = object == null ? void 0 : object.amount_refunded) != null ? _ref15 : 0)),
|
|
1073
|
+
chargeId: String((_ref16 = object == null ? void 0 : object.id) != null ? _ref16 : ''),
|
|
1074
|
+
currency: String((_ref17 = object == null ? void 0 : object.currency) != null ? _ref17 : 'usd'),
|
|
1075
|
+
stripeCustomerId: String((_ref18 = object == null ? void 0 : object.customer) != null ? _ref18 : '')
|
|
1018
1076
|
});
|
|
1019
1077
|
}
|
|
1020
1078
|
}
|
|
@@ -1036,9 +1094,9 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
1036
1094
|
// comes back by the AGL-1794 policy. `won` and `warning_closed` record
|
|
1037
1095
|
// the outcome and move nothing.
|
|
1038
1096
|
if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
|
|
1039
|
-
var
|
|
1040
|
-
const disputeId = String((
|
|
1041
|
-
const paymentIntentId = String((
|
|
1097
|
+
var _ref19, _ref20;
|
|
1098
|
+
const disputeId = String((_ref19 = object == null ? void 0 : object.id) != null ? _ref19 : '');
|
|
1099
|
+
const paymentIntentId = String((_ref20 = object == null ? void 0 : object.payment_intent) != null ? _ref20 : '');
|
|
1042
1100
|
// Reported to the route so it can tell "marketplace handled it" from
|
|
1043
1101
|
// "nothing handled it" (AGL-2429). Left false by the guard below on
|
|
1044
1102
|
// purpose: a dispute carrying no payment intent cannot be joined to a
|
|
@@ -1047,15 +1105,15 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
1047
1105
|
// The publisher the disputed sale paid (AGL-3365), for the seller ledger.
|
|
1048
1106
|
let sellerOrgId = '';
|
|
1049
1107
|
if (disputeId && paymentIntentId) {
|
|
1050
|
-
var
|
|
1108
|
+
var _ref21;
|
|
1051
1109
|
const firestore = firebaseAdmin.app().firestore();
|
|
1052
1110
|
const purchases = await firestore.collection('marketplacePurchases').where('paymentIntentId', '==', paymentIntentId).limit(1).get();
|
|
1053
1111
|
if (!purchases.empty) {
|
|
1054
|
-
var _purchase_get1,
|
|
1112
|
+
var _purchase_get1, _ref22;
|
|
1055
1113
|
claimed = true;
|
|
1056
1114
|
const purchase = purchases.docs[0];
|
|
1057
1115
|
sellerOrgId = String((_purchase_get1 = purchase.get('sellerOrgId')) != null ? _purchase_get1 : '');
|
|
1058
|
-
const status = String((
|
|
1116
|
+
const status = String((_ref22 = object == null ? void 0 : object.status) != null ? _ref22 : '');
|
|
1059
1117
|
if (type === 'charge.dispute.created') {
|
|
1060
1118
|
await purchase.ref.set({
|
|
1061
1119
|
disputeId,
|
|
@@ -1065,7 +1123,7 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
1065
1123
|
merge: true
|
|
1066
1124
|
});
|
|
1067
1125
|
} else if (status === 'lost') {
|
|
1068
|
-
var
|
|
1126
|
+
var _ref23, _ref24, _ref25;
|
|
1069
1127
|
// Revoke, report, pull the seller's share back — the same three
|
|
1070
1128
|
// effects the refund door applies, through the same function
|
|
1071
1129
|
// (AGL-2148), so the two doors and the orphan drain cannot drift.
|
|
@@ -1074,9 +1132,9 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
1074
1132
|
kind: 'dispute',
|
|
1075
1133
|
id: disputeId,
|
|
1076
1134
|
paymentIntentId,
|
|
1077
|
-
amountCents: Math.round(Number((
|
|
1078
|
-
chargeId: String((
|
|
1079
|
-
currency: String((
|
|
1135
|
+
amountCents: Math.round(Number((_ref23 = object == null ? void 0 : object.amount) != null ? _ref23 : 0)),
|
|
1136
|
+
chargeId: String((_ref24 = object == null ? void 0 : object.charge) != null ? _ref24 : ''),
|
|
1137
|
+
currency: String((_ref25 = object == null ? void 0 : object.currency) != null ? _ref25 : 'usd'),
|
|
1080
1138
|
// A dispute event carries no Stripe customer; the GA hit falls
|
|
1081
1139
|
// back to the buyer uid on the purchase, as it always has.
|
|
1082
1140
|
stripeCustomerId: ''
|
|
@@ -1095,7 +1153,7 @@ async function reverseMarketplaceSellerShare(purchaseRef, cause) {
|
|
|
1095
1153
|
merge: true
|
|
1096
1154
|
});
|
|
1097
1155
|
}
|
|
1098
|
-
} else if (type === 'charge.dispute.closed' && String((
|
|
1156
|
+
} else if (type === 'charge.dispute.closed' && String((_ref21 = object == null ? void 0 : object.status) != null ? _ref21 : '') === 'lost') {
|
|
1099
1157
|
// The refund branch's window, by the bank's door (AGL-2148): only a
|
|
1100
1158
|
// LOST closed dispute moves money, so only that state is worth
|
|
1101
1159
|
// parking. A `created` landing in the same window loses nothing but
|