@aglyn/plugins-marketplace 1.2.0 → 1.3.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.
Files changed (73) hide show
  1. package/README.md +1 -1
  2. package/package.json +14 -14
  3. package/src/lib/components/listing-content.component.js +11 -3
  4. package/src/lib/components/listing-content.component.js.map +1 -1
  5. package/src/lib/components/marketplace-pay-panel.component.d.ts +67 -0
  6. package/src/lib/components/marketplace-pay-panel.component.js +228 -0
  7. package/src/lib/components/marketplace-pay-panel.component.js.map +1 -0
  8. package/src/lib/components/publish-artifact-dialog.component.d.ts +1 -1
  9. package/src/lib/components/publish-artifact-dialog.component.js +144 -20
  10. package/src/lib/components/publish-artifact-dialog.component.js.map +1 -1
  11. package/src/lib/components/publish-starter-fields.component.d.ts +35 -0
  12. package/src/lib/components/publish-starter-fields.component.js +176 -0
  13. package/src/lib/components/publish-starter-fields.component.js.map +1 -0
  14. package/src/lib/components/starter-picker-shelf.component.d.ts +2 -2
  15. package/src/lib/components/starter-picker-shelf.component.js +28 -6
  16. package/src/lib/components/starter-picker-shelf.component.js.map +1 -1
  17. package/src/lib/hooks/use-marketplace-actions.d.ts +9 -3
  18. package/src/lib/hooks/use-marketplace-actions.js +47 -89
  19. package/src/lib/hooks/use-marketplace-actions.js.map +1 -1
  20. package/src/lib/hooks/use-marketplace-pay.d.ts +70 -0
  21. package/src/lib/hooks/use-marketplace-pay.js +359 -0
  22. package/src/lib/hooks/use-marketplace-pay.js.map +1 -0
  23. package/src/lib/hooks/use-starter-publish-facts.d.ts +25 -0
  24. package/src/lib/hooks/use-starter-publish-facts.js +116 -0
  25. package/src/lib/hooks/use-starter-publish-facts.js.map +1 -0
  26. package/src/lib/hooks/use-starter-purchase.d.ts +17 -22
  27. package/src/lib/hooks/use-starter-purchase.js +26 -73
  28. package/src/lib/hooks/use-starter-purchase.js.map +1 -1
  29. package/src/lib/model/marketplace-invoice.d.ts +52 -0
  30. package/src/lib/model/marketplace-invoice.js +75 -0
  31. package/src/lib/model/marketplace-invoice.js.map +1 -0
  32. package/src/lib/model/marketplace-pay.d.ts +42 -0
  33. package/src/lib/model/marketplace-pay.js +33 -0
  34. package/src/lib/model/marketplace-pay.js.map +1 -0
  35. package/src/lib/model/platform-seller.d.ts +37 -0
  36. package/src/lib/model/platform-seller.js +36 -0
  37. package/src/lib/model/platform-seller.js.map +1 -0
  38. package/src/lib/model/starter-publish.d.ts +159 -0
  39. package/src/lib/model/starter-publish.js +189 -0
  40. package/src/lib/model/starter-publish.js.map +1 -0
  41. package/src/lib/model/starter-query.d.ts +0 -8
  42. package/src/lib/model/starter-query.js +0 -10
  43. package/src/lib/model/starter-query.js.map +1 -1
  44. package/src/lib/server/billing-webhook.d.ts +6 -0
  45. package/src/lib/server/billing-webhook.js +269 -211
  46. package/src/lib/server/billing-webhook.js.map +1 -1
  47. package/src/lib/server/checkout.d.ts +2 -48
  48. package/src/lib/server/checkout.js +21 -241
  49. package/src/lib/server/checkout.js.map +1 -1
  50. package/src/lib/server/pay.d.ts +18 -0
  51. package/src/lib/server/pay.js +540 -0
  52. package/src/lib/server/pay.js.map +1 -0
  53. package/src/lib/server/platform-org.d.ts +23 -0
  54. package/src/lib/server/platform-org.js +27 -0
  55. package/src/lib/server/platform-org.js.map +1 -0
  56. package/src/lib/server/publish-preconditions.js +4 -1
  57. package/src/lib/server/publish-preconditions.js.map +1 -1
  58. package/src/lib/server/publish-starter.js +5 -2
  59. package/src/lib/server/publish-starter.js.map +1 -1
  60. package/src/lib/server/publisher-profile.d.ts +5 -0
  61. package/src/lib/server/publisher-profile.js +20 -1
  62. package/src/lib/server/publisher-profile.js.map +1 -1
  63. package/src/lib/server/publisher-standing.d.ts +26 -0
  64. package/src/lib/server/publisher-standing.js +66 -0
  65. package/src/lib/server/publisher-standing.js.map +1 -0
  66. package/src/lib/server/sale.d.ts +119 -0
  67. package/src/lib/server/sale.js +304 -0
  68. package/src/lib/server/sale.js.map +1 -0
  69. package/src/lib/server/starter-publish-facts.d.ts +36 -0
  70. package/src/lib/server/starter-publish-facts.js +135 -0
  71. package/src/lib/server/starter-publish-facts.js.map +1 -0
  72. package/src/lib/server.js +8 -0
  73. 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(object) {
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 — a
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
- // Sellers are orgs (AGL-652) — the ledger records which ORG earned it.
749
- const { listingId, buyerUid, buyerOrgId, sellerOrgId, feeCents, transferCents } = (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {};
750
- if (listingId && buyerUid && sellerOrgId) {
751
- var _ref1, _ref2, _ref3, _ref4;
752
- var _object_total_details;
753
- // The remittance-correct split (AGL-1544), read ONCE and used by both
754
- // the ledger and the GA hit (AGL-1639) — the two must not be able to
755
- // describe the same sale differently.
756
- //
757
- // `amount_total` is the tax-inclusive GROSS the buyer paid. Out of it:
758
- // `taxCents` is what the PLATFORM owes the state (collected under the
759
- // marketplace-provider registration, never ours), and `sellerCents` is
760
- // the fixed transfer the seller's Connect account received (their
761
- // share of the pre-tax price). What is left is what Aglyn keeps.
762
- const grossCents = Number((_ref1 = object == null ? void 0 : object.amount_total) != null ? _ref1 : 0);
763
- const taxCents = Number((_ref2 = object == null ? void 0 : (_object_total_details = object.total_details) == null ? void 0 : _object_total_details.amount_tax) != null ? _ref2 : 0);
764
- const sellerCents = Number(transferCents != null ? transferCents : 0);
765
- const netCents = grossCents - taxCents - sellerCents;
766
- // WHERE that tax is owed. Read from the session Stripe computed it
767
- // from — see `buyerTaxJurisdiction`.
768
- const jurisdiction = buyerTaxJurisdiction(object);
769
- const purchaseRef = firebaseAdmin.app().firestore().collection('marketplacePurchases').doc(String(object.id));
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 _ref5;
935
- const paymentIntentId = String((_ref5 = object == null ? void 0 : object.payment_intent) != null ? _ref5 : '');
992
+ var _ref6;
993
+ const paymentIntentId = String((_ref6 = object == null ? void 0 : object.payment_intent) != null ? _ref6 : '');
936
994
  if (paymentIntentId) {
937
- var _ref6, _ref7, _ref8, _ref9, _ref10;
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((_ref6 = object == null ? void 0 : object.id) != null ? _ref6 : ''),
1006
+ id: String((_ref7 = object == null ? void 0 : object.id) != null ? _ref7 : ''),
949
1007
  paymentIntentId,
950
- amountCents: Math.round(Number((_ref7 = object == null ? void 0 : object.amount_refunded) != null ? _ref7 : 0)),
951
- chargeId: String((_ref8 = object == null ? void 0 : object.id) != null ? _ref8 : ''),
952
- currency: String((_ref9 = object == null ? void 0 : object.currency) != null ? _ref9 : 'usd'),
953
- stripeCustomerId: String((_ref10 = object == null ? void 0 : object.customer) != null ? _ref10 : '')
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 _ref11;
997
- const paymentIntentId = String((_ref11 = object == null ? void 0 : object.payment_intent) != null ? _ref11 : '');
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 _ref12;
1004
- await reverseMarketplacePartialRefundShare(purchases.docs[0].ref, String((_ref12 = object == null ? void 0 : object.id) != null ? _ref12 : ''));
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 _ref13, _ref14, _ref15, _ref16, _ref17;
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((_ref13 = object == null ? void 0 : object.id) != null ? _ref13 : ''),
1070
+ id: String((_ref14 = object == null ? void 0 : object.id) != null ? _ref14 : ''),
1013
1071
  paymentIntentId,
1014
- amountCents: Math.round(Number((_ref14 = object == null ? void 0 : object.amount_refunded) != null ? _ref14 : 0)),
1015
- chargeId: String((_ref15 = object == null ? void 0 : object.id) != null ? _ref15 : ''),
1016
- currency: String((_ref16 = object == null ? void 0 : object.currency) != null ? _ref16 : 'usd'),
1017
- stripeCustomerId: String((_ref17 = object == null ? void 0 : object.customer) != null ? _ref17 : '')
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 _ref18, _ref19;
1040
- const disputeId = String((_ref18 = object == null ? void 0 : object.id) != null ? _ref18 : '');
1041
- const paymentIntentId = String((_ref19 = object == null ? void 0 : object.payment_intent) != null ? _ref19 : '');
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 _ref20;
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, _ref21;
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((_ref21 = object == null ? void 0 : object.status) != null ? _ref21 : '');
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 _ref22, _ref23, _ref24;
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((_ref22 = object == null ? void 0 : object.amount) != null ? _ref22 : 0)),
1078
- chargeId: String((_ref23 = object == null ? void 0 : object.charge) != null ? _ref23 : ''),
1079
- currency: String((_ref24 = object == null ? void 0 : object.currency) != null ? _ref24 : 'usd'),
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((_ref20 = object == null ? void 0 : object.status) != null ? _ref20 : '') === 'lost') {
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