@aglyn/plugins-marketplace 1.1.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.
- 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/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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).
|
|
19
|
+
*
|
|
20
|
+
* "Software as a service (SaaS) — business use". Sent explicitly on the line
|
|
21
|
+
* item below, because this session builds its product ad hoc from
|
|
22
|
+
* `price_data` and an ad-hoc product with no `tax_code` is classified by the
|
|
23
|
+
* ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard
|
|
24
|
+
* for Aglyn's own subscription products (AGL-1537/1877), which happens to be
|
|
25
|
+
* this same string.
|
|
26
|
+
*
|
|
27
|
+
* So this is not a change of tax position, and deliberately not: it is the
|
|
28
|
+
* position Aglyn already collects on, already verified, and already states in
|
|
29
|
+
* a signed filing. The pending Texas private letter ruling request proposes at
|
|
30
|
+
* Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code
|
|
31
|
+
* § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the
|
|
32
|
+
* charge; Part 4.5 ¶ 4 represents to the Comptroller that "the payment
|
|
33
|
+
* processor's tax classification applied to these charges is the
|
|
34
|
+
* software-as-a-service category" and that the 80% measure was confirmed by
|
|
35
|
+
* computation. AGL-1817 ran that computation against the live account: $6.60
|
|
36
|
+
* of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.
|
|
37
|
+
*
|
|
38
|
+
* What was actually broken is that none of that was expressed HERE. The
|
|
39
|
+
* classification of every marketplace sale hung on a Dashboard field owned by
|
|
40
|
+
* a different product line. Re-preset the account for storefront goods or
|
|
41
|
+
* bookings and these sales silently re-classify, moving the Texas base off the
|
|
42
|
+
* 80% the filing describes — with no diff to review, no failing test, and no
|
|
43
|
+
* symptom short of reconciling a return. Naming it here is what makes the
|
|
44
|
+
* filed representation survive a Dashboard edit.
|
|
45
|
+
*
|
|
46
|
+
* NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,
|
|
47
|
+
* which stamps Aglyn's OWN products with the same string today. They answer
|
|
48
|
+
* different questions — Aglyn's first-party subscription vs. a third party's
|
|
49
|
+
* sale through Aglyn's marketplace — and the ruling request itself flags at
|
|
50
|
+
* Part 8 ¶ 8 that the Comptroller could characterize the two differently. One
|
|
51
|
+
* shared constant would make a divergence the filing anticipates impossible to
|
|
52
|
+
* express without touching the other business.
|
|
53
|
+
*
|
|
54
|
+
* DO NOT change this value to a downloadable-software code (`txcd_10202003`,
|
|
55
|
+
* `txcd_10203001`) on the reasoning that a plugin is "downloaded". That is the
|
|
56
|
+
* OPPOSING characterization — tangible personal property under Rule 3.308,
|
|
57
|
+
* taxable in full — which Part 6.1 argues against and which the filing already
|
|
58
|
+
* states against itself, in full, at Part 8 ¶¶ 1-2. Adopting
|
|
59
|
+
* it here would raise the tax charged to every buyer by a quarter of the base
|
|
60
|
+
* and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling
|
|
61
|
+
* goes that way the change is a one-line edit at this constant plus a
|
|
62
|
+
* prospective conformance — which is exactly what naming it here buys.
|
|
63
|
+
*/
|
|
64
|
+
export declare const MARKETPLACE_TAX_CODE = "txcd_10103001";
|
|
65
|
+
/** Who is buying: the person, the workspace the licence is for, and what they may do in it. */
|
|
66
|
+
export interface SaleBuyer {
|
|
67
|
+
uid: string;
|
|
68
|
+
orgId: string;
|
|
69
|
+
permissions: {
|
|
70
|
+
createHosts?: boolean;
|
|
71
|
+
installPlugins?: boolean;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** One sale, decided: everything the payment doors need and nothing they chose. */
|
|
75
|
+
export interface MarketplaceSale {
|
|
76
|
+
listing: Record<string, any>;
|
|
77
|
+
/** A site starter is bought to create a site with, everything else to install. */
|
|
78
|
+
siteStarter: boolean;
|
|
79
|
+
sellerOrgId: string;
|
|
80
|
+
feePercent: number;
|
|
81
|
+
amountCents: number;
|
|
82
|
+
feeCents: number;
|
|
83
|
+
/** What the seller receives: 0 when the platform's own org is the seller. */
|
|
84
|
+
transferCents: number;
|
|
85
|
+
/** The seller's Connect account; absent when {@link platformSale}. */
|
|
86
|
+
accountId: string | undefined;
|
|
87
|
+
/** The platform's own org is selling, so no transfer is made at all. */
|
|
88
|
+
platformSale: boolean;
|
|
89
|
+
}
|
|
90
|
+
/** The answer a door sends instead of taking money: the status and body, decided together. */
|
|
91
|
+
export interface SaleRefusal {
|
|
92
|
+
status: number;
|
|
93
|
+
body: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
export type SaleResolution = {
|
|
96
|
+
sale: MarketplaceSale;
|
|
97
|
+
} | {
|
|
98
|
+
refusal: SaleRefusal;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).
|
|
102
|
+
*
|
|
103
|
+
* One value, for both doors that take money for a listing: the hosted
|
|
104
|
+
* checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of
|
|
105
|
+
* these gates is how a door comes to sell something the other refuses, and
|
|
106
|
+
* every refusal below has already cost a real sale or a real refund to learn.
|
|
107
|
+
*
|
|
108
|
+
* The caller has settled WHO is buying, through the person's own membership;
|
|
109
|
+
* this settles whether that workspace may buy this listing, from this seller,
|
|
110
|
+
* today, and at what split.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately before the door's idempotency claim, so none of these burns the
|
|
113
|
+
* key: each is a state somebody fixes before pressing the same button again.
|
|
114
|
+
*/
|
|
115
|
+
export declare function resolveSale(input: {
|
|
116
|
+
firestore: FirebaseFirestore.Firestore;
|
|
117
|
+
listingId: string;
|
|
118
|
+
buyer: SaleBuyer;
|
|
119
|
+
}): Promise<SaleResolution>;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
|
|
17
|
+
import { checkEntitlement, isListingWideRevocation, resolveMarketplaceFeePct } from "@aglyn/aglyn/server";
|
|
18
|
+
import { permissionRefusalText } from "@aglyn/aglyn/app-utils/org-permissions";
|
|
19
|
+
import { SITE_STARTER_ARTIFACT_TYPE } from "@aglyn/aglyn/app-utils/site-starter";
|
|
20
|
+
import { isListingBrowsable, isPrivateListing, listingArtifactType } from "../model/marketplace.js";
|
|
21
|
+
import { sellsAsPlatform } from "../model/platform-seller.js";
|
|
22
|
+
import { readPlatformOrgId } from "./platform-org.js";
|
|
23
|
+
import { canActAsPublisher, resolvePublisherProfile } from "./publisher-profile.js";
|
|
24
|
+
import { hasLivePurchase } from "./purchase-entitlement.js";
|
|
25
|
+
import { isOrgMember } from "./sale-risk.js";
|
|
26
|
+
/**
|
|
27
|
+
* THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).
|
|
28
|
+
*
|
|
29
|
+
* "Software as a service (SaaS) — business use". Sent explicitly on the line
|
|
30
|
+
* item below, because this session builds its product ad hoc from
|
|
31
|
+
* `price_data` and an ad-hoc product with no `tax_code` is classified by the
|
|
32
|
+
* ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard
|
|
33
|
+
* for Aglyn's own subscription products (AGL-1537/1877), which happens to be
|
|
34
|
+
* this same string.
|
|
35
|
+
*
|
|
36
|
+
* So this is not a change of tax position, and deliberately not: it is the
|
|
37
|
+
* position Aglyn already collects on, already verified, and already states in
|
|
38
|
+
* a signed filing. The pending Texas private letter ruling request proposes at
|
|
39
|
+
* Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code
|
|
40
|
+
* § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the
|
|
41
|
+
* charge; Part 4.5 ¶ 4 represents to the Comptroller that "the payment
|
|
42
|
+
* processor's tax classification applied to these charges is the
|
|
43
|
+
* software-as-a-service category" and that the 80% measure was confirmed by
|
|
44
|
+
* computation. AGL-1817 ran that computation against the live account: $6.60
|
|
45
|
+
* of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.
|
|
46
|
+
*
|
|
47
|
+
* What was actually broken is that none of that was expressed HERE. The
|
|
48
|
+
* classification of every marketplace sale hung on a Dashboard field owned by
|
|
49
|
+
* a different product line. Re-preset the account for storefront goods or
|
|
50
|
+
* bookings and these sales silently re-classify, moving the Texas base off the
|
|
51
|
+
* 80% the filing describes — with no diff to review, no failing test, and no
|
|
52
|
+
* symptom short of reconciling a return. Naming it here is what makes the
|
|
53
|
+
* filed representation survive a Dashboard edit.
|
|
54
|
+
*
|
|
55
|
+
* NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,
|
|
56
|
+
* which stamps Aglyn's OWN products with the same string today. They answer
|
|
57
|
+
* different questions — Aglyn's first-party subscription vs. a third party's
|
|
58
|
+
* sale through Aglyn's marketplace — and the ruling request itself flags at
|
|
59
|
+
* Part 8 ¶ 8 that the Comptroller could characterize the two differently. One
|
|
60
|
+
* shared constant would make a divergence the filing anticipates impossible to
|
|
61
|
+
* express without touching the other business.
|
|
62
|
+
*
|
|
63
|
+
* DO NOT change this value to a downloadable-software code (`txcd_10202003`,
|
|
64
|
+
* `txcd_10203001`) on the reasoning that a plugin is "downloaded". That is the
|
|
65
|
+
* OPPOSING characterization — tangible personal property under Rule 3.308,
|
|
66
|
+
* taxable in full — which Part 6.1 argues against and which the filing already
|
|
67
|
+
* states against itself, in full, at Part 8 ¶¶ 1-2. Adopting
|
|
68
|
+
* it here would raise the tax charged to every buyer by a quarter of the base
|
|
69
|
+
* and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling
|
|
70
|
+
* goes that way the change is a one-line edit at this constant plus a
|
|
71
|
+
* prospective conformance — which is exactly what naming it here buys.
|
|
72
|
+
*/ export const MARKETPLACE_TAX_CODE = 'txcd_10103001';
|
|
73
|
+
const refuse = (status, body)=>({
|
|
74
|
+
refusal: {
|
|
75
|
+
status,
|
|
76
|
+
body
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).
|
|
81
|
+
*
|
|
82
|
+
* One value, for both doors that take money for a listing: the hosted
|
|
83
|
+
* checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of
|
|
84
|
+
* these gates is how a door comes to sell something the other refuses, and
|
|
85
|
+
* every refusal below has already cost a real sale or a real refund to learn.
|
|
86
|
+
*
|
|
87
|
+
* The caller has settled WHO is buying, through the person's own membership;
|
|
88
|
+
* this settles whether that workspace may buy this listing, from this seller,
|
|
89
|
+
* today, and at what split.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately before the door's idempotency claim, so none of these burns the
|
|
92
|
+
* key: each is a state somebody fixes before pressing the same button again.
|
|
93
|
+
*/ export async function resolveSale(input) {
|
|
94
|
+
var _listing_priceUsd, _listing_profileId, _sellerOrgDoc_data;
|
|
95
|
+
const { firestore, listingId, buyer } = input;
|
|
96
|
+
const listingSnapshot = await firestore.collection('marketplaceListings').doc(listingId).get();
|
|
97
|
+
const listing = listingSnapshot.data();
|
|
98
|
+
if (!listing || listing.deletedAt) {
|
|
99
|
+
return refuse(404, {
|
|
100
|
+
error: 'Unknown listing'
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// A site starter is bought to create a site with (AGL-4049), so buying
|
|
104
|
+
// one takes the permission that creates sites, as picking one in Create
|
|
105
|
+
// site does; everything else is bought to install, as before. Asked of
|
|
106
|
+
// the listing, never of the request, and before anything else about the
|
|
107
|
+
// sale is read.
|
|
108
|
+
const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE;
|
|
109
|
+
if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {
|
|
110
|
+
return refuse(403, {
|
|
111
|
+
error: siteStarter ? permissionRefusalText('hosts.create') : 'Your organization role does not allow installing from the marketplace'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Review gating holds on the PAID path too (AGL-1543): browse hides
|
|
115
|
+
// rejected/submitted/in_review listings, so a direct checkout call must
|
|
116
|
+
// not be a side door that takes money for one. Absent = legacy = listed
|
|
117
|
+
// (AGL-432), and the same 404 as an unknown listing — purchasability is
|
|
118
|
+
// not a probe for review state.
|
|
119
|
+
const reviewStatus = listing.reviewStatus;
|
|
120
|
+
if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {
|
|
121
|
+
return refuse(404, {
|
|
122
|
+
error: 'Unknown listing'
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).
|
|
126
|
+
//
|
|
127
|
+
// The check above is a hand-rolled subset of `isListingBrowsable`, and
|
|
128
|
+
// the two lines it dropped are the two that matter here: `hiddenAt` and
|
|
129
|
+
// `isPrivateListing`. A staff takedown does NOT set a blocking
|
|
130
|
+
// `reviewStatus` — `plugin-reviews` demotes a verified listing to
|
|
131
|
+
// `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a
|
|
132
|
+
// takedown this route read `reviewStatus: 'listed'` and sold the listing
|
|
133
|
+
// in full, while `install-plugin` refused the very same listing at
|
|
134
|
+
// `hiddenAt` with a 404. The buyer was charged for something the platform
|
|
135
|
+
// had already decided nobody may install, and the only way out was a
|
|
136
|
+
// refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until
|
|
137
|
+
// the partial-refund fix) a real hole in the split.
|
|
138
|
+
//
|
|
139
|
+
// Both tests are kept rather than replaced by the shared predicate: for a
|
|
140
|
+
// NON-plugin artifact `isListingBrowsable` returns true whatever the
|
|
141
|
+
// review status says, because pre-publication review is plugin-only. This
|
|
142
|
+
// route has always been stricter than that, and a sale is the wrong place
|
|
143
|
+
// to loosen a gate.
|
|
144
|
+
//
|
|
145
|
+
// No owner exemption, unlike the install routes: `canActAsPublisher`
|
|
146
|
+
// below already refuses a publisher buying their own listing, so the
|
|
147
|
+
// buyer here is never the owner.
|
|
148
|
+
if (!isListingBrowsable(listing) || isPrivateListing(listing)) {
|
|
149
|
+
return refuse(404, {
|
|
150
|
+
error: 'Unknown listing'
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const priceUsd = Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0);
|
|
154
|
+
if (!(priceUsd > 0)) {
|
|
155
|
+
return refuse(400, {
|
|
156
|
+
error: 'Listing is free'
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.
|
|
160
|
+
const sellerOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
|
|
161
|
+
// ANY member of the publishing workspace, and any purchase FOR it
|
|
162
|
+
// (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an
|
|
163
|
+
// editor of the publisher — or an owner buying "for" the publisher's own
|
|
164
|
+
// workspace through a role that can install — passed it, and a publisher
|
|
165
|
+
// could pay itself: the stolen-card cash-out the payout exists to make
|
|
166
|
+
// worth doing. A workspace one step removed is the sale screen's to flag.
|
|
167
|
+
if (buyer.orgId === sellerOrgId || await canActAsPublisher(firestore, buyer.uid, sellerOrgId) || await isOrgMember(firestore, sellerOrgId, buyer.uid)) {
|
|
168
|
+
return refuse(400, {
|
|
169
|
+
error: 'Your organization published this listing'
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// You cannot buy the same component twice (AGL-1697).
|
|
173
|
+
//
|
|
174
|
+
// This route validated review status, self-purchase and the seller's
|
|
175
|
+
// entitlements, and then never asked the one question the buyer cares
|
|
176
|
+
// about: do they already own it. A marketplace listing is a buy-once good —
|
|
177
|
+
// a second purchase buys nothing, since `requirePurchase` is satisfied by
|
|
178
|
+
// the first — so a buyer who clicked Buy again from a stale tab, or came
|
|
179
|
+
// back through a bookmarked checkout, was simply charged twice for it.
|
|
180
|
+
//
|
|
181
|
+
// Deliberately the SAME predicate the seven install routes gate on
|
|
182
|
+
// (AGL-1546/1699) rather than a second query beside it: "has this buyer
|
|
183
|
+
// paid for this listing" must have one answer, or the marketplace can take
|
|
184
|
+
// money for something it will not install, or refuse to sell something it
|
|
185
|
+
// will not install either. That predicate reads a fully REFUNDED purchase
|
|
186
|
+
// as absent, which is exactly right here too — a refunded buyer must be
|
|
187
|
+
// able to buy again, and a refunded row sitting beside a live one does not
|
|
188
|
+
// veto it.
|
|
189
|
+
//
|
|
190
|
+
// 409 rather than 400: nothing about the request is malformed, the buyer
|
|
191
|
+
// is just asking for a state that already holds.
|
|
192
|
+
//
|
|
193
|
+
// KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this
|
|
194
|
+
// guard from a blocked sale back into a real one. A licence belongs to a
|
|
195
|
+
// workspace, so an agency developer who bought a component for one client
|
|
196
|
+
// must be able to buy it again for the next — the person-keyed version of
|
|
197
|
+
// this check answered `409 already_purchased` to exactly the second sale
|
|
198
|
+
// we want. It still refuses the case it was written for: the same
|
|
199
|
+
// workspace buying the same listing twice.
|
|
200
|
+
//
|
|
201
|
+
// A pre-AGL-2331 purchase still refuses, through the legacy grant inside
|
|
202
|
+
// the shared predicate: that buyer can already install this listing
|
|
203
|
+
// anywhere, so selling them a second copy would take money for nothing.
|
|
204
|
+
if (await hasLivePurchase({
|
|
205
|
+
firestore,
|
|
206
|
+
buyerUid: buyer.uid,
|
|
207
|
+
buyerOrgId: buyer.orgId,
|
|
208
|
+
listingId
|
|
209
|
+
})) {
|
|
210
|
+
return refuse(409, {
|
|
211
|
+
error: 'This organization already owns this listing — install it from ' + 'your library.',
|
|
212
|
+
code: 'already_purchased'
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// The kill switch, on the SELL side too (AGL-2288).
|
|
216
|
+
//
|
|
217
|
+
// `revocations/{listingId}` with `versions: 'all'` is the listing-wide
|
|
218
|
+
// kill: `install-plugin` 409s on it through this same predicate, and the
|
|
219
|
+
// tenant runtime renders the disabled placeholder for anything already
|
|
220
|
+
// pinned. Selling into that state takes money for an artifact the
|
|
221
|
+
// platform has switched off.
|
|
222
|
+
//
|
|
223
|
+
// Only `'all'` is consulted, deliberately. A per-version revocation
|
|
224
|
+
// (AGL-1085) stops one build while other approved versions stay
|
|
225
|
+
// installable, and refusing the sale for it would turn a targeted control
|
|
226
|
+
// into a de-listing. Whether the newest approved version happens to be
|
|
227
|
+
// the revoked one is a question `install-plugin` answers with live data;
|
|
228
|
+
// duplicating that resolution here would be a second copy of it.
|
|
229
|
+
//
|
|
230
|
+
// One extra read on a path that already makes several Firestore and
|
|
231
|
+
// Stripe round trips, and it is the cheap half of the takedown check
|
|
232
|
+
// above: a hand-written revocation doc — which the rules permit any staff
|
|
233
|
+
// client to write — is caught here even when `hiddenAt` was never set.
|
|
234
|
+
const revocation = (await firestore.collection('revocations').doc(listingId).get()).data();
|
|
235
|
+
if (isListingWideRevocation(revocation)) {
|
|
236
|
+
return refuse(404, {
|
|
237
|
+
error: 'Unknown listing'
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const publisher = await resolvePublisherProfile(firestore, sellerOrgId);
|
|
241
|
+
const accountId = publisher == null ? void 0 : publisher.stripeAccountId;
|
|
242
|
+
// The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw
|
|
243
|
+
// `plan` field: `resolveMarketplaceFeePct` prices a dead subscription
|
|
244
|
+
// as free-plan (30%) while the stale field still says pro, and honors
|
|
245
|
+
// a negotiated per-org `marketplaceFeePct` override. Same read gates
|
|
246
|
+
// SALE time on `marketplaceSelling` — publish already refuses without
|
|
247
|
+
// it, and a publisher who downgrades must stop selling, not merely
|
|
248
|
+
// stop publishing. Resolved per request; entitlements are never cached.
|
|
249
|
+
const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get();
|
|
250
|
+
const sellerOrg = (_sellerOrgDoc_data = sellerOrgDoc.data()) != null ? _sellerOrgDoc_data : {};
|
|
251
|
+
const feePercent = resolveMarketplaceFeePct(sellerOrg);
|
|
252
|
+
// THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so
|
|
253
|
+
// no account to be ready and no transfer to make. Asked here, ahead of the
|
|
254
|
+
// payout gate, which is the only refusal it changes: a lock and the plan
|
|
255
|
+
// below hold for it as for anyone.
|
|
256
|
+
const platformSale = sellsAsPlatform({
|
|
257
|
+
sellerOrgId,
|
|
258
|
+
feePercent,
|
|
259
|
+
platformOrgId: readPlatformOrgId()
|
|
260
|
+
});
|
|
261
|
+
if (!platformSale && !merchantAccountIsReady({
|
|
262
|
+
accountId,
|
|
263
|
+
chargesEnabled: publisher == null ? void 0 : publisher.stripeChargesEnabled,
|
|
264
|
+
accountLivemode: publisher == null ? void 0 : publisher.stripeAccountLivemode
|
|
265
|
+
}, {
|
|
266
|
+
subject: `marketplace publisher org ${sellerOrgId}`
|
|
267
|
+
})) {
|
|
268
|
+
return refuse(409, {
|
|
269
|
+
error: 'The publisher has not enabled payouts yet'
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// A LOCKED publisher sells nothing (AGL-3365). Any lock, not only a
|
|
273
|
+
// security one: a sale to a workspace staff have stopped is money moving
|
|
274
|
+
// to it, and the lockdown's payout pause cannot reach a share that is
|
|
275
|
+
// never charged. The same answer as a taken-down listing — a lock is not
|
|
276
|
+
// a buyer's business.
|
|
277
|
+
if (sellerOrg.suspendedAt != null) {
|
|
278
|
+
return refuse(404, {
|
|
279
|
+
error: 'Unknown listing'
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {
|
|
283
|
+
return refuse(409, {
|
|
284
|
+
error: "The publisher's current plan does not include marketplace selling"
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const amountCents = Math.round(priceUsd * 100);
|
|
288
|
+
const feeCents = Math.round(amountCents * feePercent / 100);
|
|
289
|
+
return {
|
|
290
|
+
sale: {
|
|
291
|
+
listing,
|
|
292
|
+
siteStarter,
|
|
293
|
+
sellerOrgId,
|
|
294
|
+
feePercent,
|
|
295
|
+
amountCents,
|
|
296
|
+
feeCents,
|
|
297
|
+
transferCents: platformSale ? 0 : amountCents - feeCents,
|
|
298
|
+
accountId: platformSale ? undefined : accountId,
|
|
299
|
+
platformSale
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
//# sourceMappingURL=sale.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport {\n checkEntitlement,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { permissionRefusalText } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { SITE_STARTER_ARTIFACT_TYPE } from '@aglyn/aglyn/app-utils/site-starter'\nimport { isListingBrowsable, isPrivateListing, listingArtifactType } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\nimport { canActAsPublisher, resolvePublisherProfile } from './publisher-profile'\nimport { hasLivePurchase } from './purchase-entitlement'\nimport { isOrgMember } from './sale-risk'\n\n/**\n * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).\n *\n * \"Software as a service (SaaS) — business use\". Sent explicitly on the line\n * item below, because this session builds its product ad hoc from\n * `price_data` and an ad-hoc product with no `tax_code` is classified by the\n * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard\n * for Aglyn's own subscription products (AGL-1537/1877), which happens to be\n * this same string.\n *\n * So this is not a change of tax position, and deliberately not: it is the\n * position Aglyn already collects on, already verified, and already states in\n * a signed filing. The pending Texas private letter ruling request proposes at\n * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code\n * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the\n * charge; Part 4.5 ¶ 4 represents to the Comptroller that \"the payment\n * processor's tax classification applied to these charges is the\n * software-as-a-service category\" and that the 80% measure was confirmed by\n * computation. AGL-1817 ran that computation against the live account: $6.60\n * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.\n *\n * What was actually broken is that none of that was expressed HERE. The\n * classification of every marketplace sale hung on a Dashboard field owned by\n * a different product line. Re-preset the account for storefront goods or\n * bookings and these sales silently re-classify, moving the Texas base off the\n * 80% the filing describes — with no diff to review, no failing test, and no\n * symptom short of reconciling a return. Naming it here is what makes the\n * filed representation survive a Dashboard edit.\n *\n * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,\n * which stamps Aglyn's OWN products with the same string today. They answer\n * different questions — Aglyn's first-party subscription vs. a third party's\n * sale through Aglyn's marketplace — and the ruling request itself flags at\n * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One\n * shared constant would make a divergence the filing anticipates impossible to\n * express without touching the other business.\n *\n * DO NOT change this value to a downloadable-software code (`txcd_10202003`,\n * `txcd_10203001`) on the reasoning that a plugin is \"downloaded\". That is the\n * OPPOSING characterization — tangible personal property under Rule 3.308,\n * taxable in full — which Part 6.1 argues against and which the filing already\n * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting\n * it here would raise the tax charged to every buyer by a quarter of the base\n * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling\n * goes that way the change is a one-line edit at this constant plus a\n * prospective conformance — which is exactly what naming it here buys.\n */\nexport const MARKETPLACE_TAX_CODE = 'txcd_10103001'\n\n/** Who is buying: the person, the workspace the licence is for, and what they may do in it. */\nexport interface SaleBuyer {\n uid: string\n orgId: string\n permissions: { createHosts?: boolean; installPlugins?: boolean }\n}\n\n/** One sale, decided: everything the payment doors need and nothing they chose. */\nexport interface MarketplaceSale {\n listing: Record<string, any>\n /** A site starter is bought to create a site with, everything else to install. */\n siteStarter: boolean\n sellerOrgId: string\n feePercent: number\n amountCents: number\n feeCents: number\n /** What the seller receives: 0 when the platform's own org is the seller. */\n transferCents: number\n /** The seller's Connect account; absent when {@link platformSale}. */\n accountId: string | undefined\n /** The platform's own org is selling, so no transfer is made at all. */\n platformSale: boolean\n}\n\n/** The answer a door sends instead of taking money: the status and body, decided together. */\nexport interface SaleRefusal {\n status: number\n body: Record<string, unknown>\n}\n\nexport type SaleResolution = { sale: MarketplaceSale } | { refusal: SaleRefusal }\n\nconst refuse = (status: number, body: Record<string, unknown>): SaleResolution => ({\n refusal: { status, body },\n})\n\n/**\n * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).\n *\n * One value, for both doors that take money for a listing: the hosted\n * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of\n * these gates is how a door comes to sell something the other refuses, and\n * every refusal below has already cost a real sale or a real refund to learn.\n *\n * The caller has settled WHO is buying, through the person's own membership;\n * this settles whether that workspace may buy this listing, from this seller,\n * today, and at what split.\n *\n * Deliberately before the door's idempotency claim, so none of these burns the\n * key: each is a state somebody fixes before pressing the same button again.\n */\nexport async function resolveSale(input: {\n firestore: FirebaseFirestore.Firestore\n listingId: string\n buyer: SaleBuyer\n}): Promise<SaleResolution> {\n const { firestore, listingId, buyer } = input\n const listingSnapshot = await firestore\n .collection('marketplaceListings')\n .doc(listingId)\n .get()\n const listing = listingSnapshot.data() as any\n if (!listing || listing.deletedAt) {\n return refuse(404, { error: 'Unknown listing' })\n }\n // A site starter is bought to create a site with (AGL-4049), so buying\n // one takes the permission that creates sites, as picking one in Create\n // site does; everything else is bought to install, as before. Asked of\n // the listing, never of the request, and before anything else about the\n // sale is read.\n const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE\n if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {\n return refuse(403, {\n error: siteStarter\n ? permissionRefusalText('hosts.create')\n : 'Your organization role does not allow installing from the marketplace',\n })\n }\n // Review gating holds on the PAID path too (AGL-1543): browse hides\n // rejected/submitted/in_review listings, so a direct checkout call must\n // not be a side door that takes money for one. Absent = legacy = listed\n // (AGL-432), and the same 404 as an unknown listing — purchasability is\n // not a probe for review state.\n const reviewStatus = listing.reviewStatus as string | undefined\n if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {\n return refuse(404, { error: 'Unknown listing' })\n }\n // A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).\n //\n // The check above is a hand-rolled subset of `isListingBrowsable`, and\n // the two lines it dropped are the two that matter here: `hiddenAt` and\n // `isPrivateListing`. A staff takedown does NOT set a blocking\n // `reviewStatus` — `plugin-reviews` demotes a verified listing to\n // `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a\n // takedown this route read `reviewStatus: 'listed'` and sold the listing\n // in full, while `install-plugin` refused the very same listing at\n // `hiddenAt` with a 404. The buyer was charged for something the platform\n // had already decided nobody may install, and the only way out was a\n // refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until\n // the partial-refund fix) a real hole in the split.\n //\n // Both tests are kept rather than replaced by the shared predicate: for a\n // NON-plugin artifact `isListingBrowsable` returns true whatever the\n // review status says, because pre-publication review is plugin-only. This\n // route has always been stricter than that, and a sale is the wrong place\n // to loosen a gate.\n //\n // No owner exemption, unlike the install routes: `canActAsPublisher`\n // below already refuses a publisher buying their own listing, so the\n // buyer here is never the owner.\n if (!isListingBrowsable(listing) || isPrivateListing(listing)) {\n return refuse(404, { error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return refuse(400, { error: 'Listing is free' })\n }\n // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.\n const sellerOrgId = String(listing.profileId ?? '')\n // ANY member of the publishing workspace, and any purchase FOR it\n // (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an\n // editor of the publisher — or an owner buying \"for\" the publisher's own\n // workspace through a role that can install — passed it, and a publisher\n // could pay itself: the stolen-card cash-out the payout exists to make\n // worth doing. A workspace one step removed is the sale screen's to flag.\n if (\n buyer.orgId === sellerOrgId ||\n (await canActAsPublisher(firestore, buyer.uid, sellerOrgId)) ||\n (await isOrgMember(firestore, sellerOrgId, buyer.uid))\n ) {\n return refuse(400, { error: 'Your organization published this listing' })\n }\n // You cannot buy the same component twice (AGL-1697).\n //\n // This route validated review status, self-purchase and the seller's\n // entitlements, and then never asked the one question the buyer cares\n // about: do they already own it. A marketplace listing is a buy-once good —\n // a second purchase buys nothing, since `requirePurchase` is satisfied by\n // the first — so a buyer who clicked Buy again from a stale tab, or came\n // back through a bookmarked checkout, was simply charged twice for it.\n //\n // Deliberately the SAME predicate the seven install routes gate on\n // (AGL-1546/1699) rather than a second query beside it: \"has this buyer\n // paid for this listing\" must have one answer, or the marketplace can take\n // money for something it will not install, or refuse to sell something it\n // will not install either. That predicate reads a fully REFUNDED purchase\n // as absent, which is exactly right here too — a refunded buyer must be\n // able to buy again, and a refunded row sitting beside a live one does not\n // veto it.\n //\n // 409 rather than 400: nothing about the request is malformed, the buyer\n // is just asking for a state that already holds.\n //\n // KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this\n // guard from a blocked sale back into a real one. A licence belongs to a\n // workspace, so an agency developer who bought a component for one client\n // must be able to buy it again for the next — the person-keyed version of\n // this check answered `409 already_purchased` to exactly the second sale\n // we want. It still refuses the case it was written for: the same\n // workspace buying the same listing twice.\n //\n // A pre-AGL-2331 purchase still refuses, through the legacy grant inside\n // the shared predicate: that buyer can already install this listing\n // anywhere, so selling them a second copy would take money for nothing.\n if (\n await hasLivePurchase({\n firestore,\n buyerUid: buyer.uid,\n buyerOrgId: buyer.orgId,\n listingId,\n })\n ) {\n return refuse(409, {\n error:\n 'This organization already owns this listing — install it from ' +\n 'your library.',\n code: 'already_purchased',\n })\n }\n // The kill switch, on the SELL side too (AGL-2288).\n //\n // `revocations/{listingId}` with `versions: 'all'` is the listing-wide\n // kill: `install-plugin` 409s on it through this same predicate, and the\n // tenant runtime renders the disabled placeholder for anything already\n // pinned. Selling into that state takes money for an artifact the\n // platform has switched off.\n //\n // Only `'all'` is consulted, deliberately. A per-version revocation\n // (AGL-1085) stops one build while other approved versions stay\n // installable, and refusing the sale for it would turn a targeted control\n // into a de-listing. Whether the newest approved version happens to be\n // the revoked one is a question `install-plugin` answers with live data;\n // duplicating that resolution here would be a second copy of it.\n //\n // One extra read on a path that already makes several Firestore and\n // Stripe round trips, and it is the cheap half of the takedown check\n // above: a hand-written revocation doc — which the rules permit any staff\n // client to write — is caught here even when `hiddenAt` was never set.\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isListingWideRevocation(revocation)) {\n return refuse(404, { error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\n // The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw\n // `plan` field: `resolveMarketplaceFeePct` prices a dead subscription\n // as free-plan (30%) while the stale field still says pro, and honors\n // a negotiated per-org `marketplaceFeePct` override. Same read gates\n // SALE time on `marketplaceSelling` — publish already refuses without\n // it, and a publisher who downgrades must stop selling, not merely\n // stop publishing. Resolved per request; entitlements are never cached.\n const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get()\n const sellerOrg = (sellerOrgDoc.data() ?? {}) as any\n const feePercent = resolveMarketplaceFeePct(sellerOrg)\n // THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so\n // no account to be ready and no transfer to make. Asked here, ahead of the\n // payout gate, which is the only refusal it changes: a lock and the plan\n // below hold for it as for anyone.\n const platformSale = sellsAsPlatform({\n sellerOrgId,\n feePercent,\n platformOrgId: readPlatformOrgId(),\n })\n if (\n !platformSale &&\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return refuse(409, { error: 'The publisher has not enabled payouts yet' })\n }\n // A LOCKED publisher sells nothing (AGL-3365). Any lock, not only a\n // security one: a sale to a workspace staff have stopped is money moving\n // to it, and the lockdown's payout pause cannot reach a share that is\n // never charged. The same answer as a taken-down listing — a lock is not\n // a buyer's business.\n if (sellerOrg.suspendedAt != null) {\n return refuse(404, { error: 'Unknown listing' })\n }\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return refuse(409, {\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n return {\n sale: {\n listing,\n siteStarter,\n sellerOrgId,\n feePercent,\n amountCents,\n feeCents,\n transferCents: platformSale ? 0 : amountCents - feeCents,\n accountId: platformSale ? undefined : accountId,\n platformSale,\n },\n }\n}\n"],"names":["merchantAccountIsReady","checkEntitlement","isListingWideRevocation","resolveMarketplaceFeePct","permissionRefusalText","SITE_STARTER_ARTIFACT_TYPE","isListingBrowsable","isPrivateListing","listingArtifactType","sellsAsPlatform","readPlatformOrgId","canActAsPublisher","resolvePublisherProfile","hasLivePurchase","isOrgMember","MARKETPLACE_TAX_CODE","refuse","status","body","refusal","resolveSale","input","listing","sellerOrgDoc","firestore","listingId","buyer","listingSnapshot","collection","doc","get","data","deletedAt","error","siteStarter","permissions","createHosts","installPlugins","reviewStatus","priceUsd","Number","sellerOrgId","String","profileId","orgId","uid","buyerUid","buyerOrgId","code","revocation","publisher","accountId","stripeAccountId","sellerOrg","feePercent","platformSale","platformOrgId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","suspendedAt","amountCents","Math","round","feeCents","sale","transferCents","undefined"],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* WHAT "PUBLISH AS A STARTER" READS BEFORE THE CLICK, ASKED OF THE SERVER
|
|
20
|
+
* (AGL-4049).
|
|
21
|
+
*
|
|
22
|
+
* `GET /api/marketplace/starter-publish-facts?hostId=` answers
|
|
23
|
+
* `StarterPublishFactsAnswer`: what the publish would capture, the built-in
|
|
24
|
+
* theme the site wears as the publish body carries it, and each setup step
|
|
25
|
+
* still in the way.
|
|
26
|
+
*
|
|
27
|
+
* The console's form works these out in the browser; the native apps ask
|
|
28
|
+
* here, so what a site's built-in theme is stays one function in core
|
|
29
|
+
* (`siteStarterBuiltinThemeOf`) rather than a port on each platform. It reads
|
|
30
|
+
* for the same person the door would take: the workspace's "Publish to the
|
|
31
|
+
* marketplace" (`marketplace.publish`), and the release flag, which staff
|
|
32
|
+
* pass. A refusal is also how an app knows to offer nothing. The door still
|
|
33
|
+
* decides: these are what it would say, read early.
|
|
34
|
+
*/
|
|
35
|
+
export declare const starterPublishFactsHandler: PluginApiHandler;
|
|
36
|
+
export default starterPublishFactsHandler;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { permissionRefusalText } from "@aglyn/aglyn/app-utils/org-permissions";
|
|
18
|
+
import { planLabelGrantingFeature } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
19
|
+
import { SITE_STARTER_MAX_SCREENS } from "@aglyn/aglyn/app-utils/site-starter";
|
|
20
|
+
import { siteStarterBuiltinThemeOf } from "@aglyn/aglyn/app-utils/site-starter-theme";
|
|
21
|
+
import { checkEntitlement } from "@aglyn/aglyn/server";
|
|
22
|
+
import { firebaseAdmin, getOrgForHost, isServerReleaseFlagOnForOrg } from "@aglyn/tenant-data-admin";
|
|
23
|
+
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
24
|
+
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
25
|
+
import { starterPublishFactsAnswer, starterPublishPages } from "../model/starter-publish.js";
|
|
26
|
+
import { PUBLISHER_PROFILES, resolvePublisherProfile } from "./publisher-profile.js";
|
|
27
|
+
import { SITE_STARTERS_UNRELEASED } from "./starters.js";
|
|
28
|
+
/**
|
|
29
|
+
* WHAT "PUBLISH AS A STARTER" READS BEFORE THE CLICK, ASKED OF THE SERVER
|
|
30
|
+
* (AGL-4049).
|
|
31
|
+
*
|
|
32
|
+
* `GET /api/marketplace/starter-publish-facts?hostId=` answers
|
|
33
|
+
* `StarterPublishFactsAnswer`: what the publish would capture, the built-in
|
|
34
|
+
* theme the site wears as the publish body carries it, and each setup step
|
|
35
|
+
* still in the way.
|
|
36
|
+
*
|
|
37
|
+
* The console's form works these out in the browser; the native apps ask
|
|
38
|
+
* here, so what a site's built-in theme is stays one function in core
|
|
39
|
+
* (`siteStarterBuiltinThemeOf`) rather than a port on each platform. It reads
|
|
40
|
+
* for the same person the door would take: the workspace's "Publish to the
|
|
41
|
+
* marketplace" (`marketplace.publish`), and the release flag, which staff
|
|
42
|
+
* pass. A refusal is also how an app knows to offer nothing. The door still
|
|
43
|
+
* decides: these are what it would say, read early.
|
|
44
|
+
*/ export const starterPublishFactsHandler = async (req, res)=>{
|
|
45
|
+
var _ref, _req_headers_authorization;
|
|
46
|
+
var _req_query;
|
|
47
|
+
if (req.method !== 'GET') {
|
|
48
|
+
return res.status(405).json({
|
|
49
|
+
error: 'Method not allowed'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const hostId = String((_ref = (_req_query = req.query) == null ? void 0 : _req_query['hostId']) != null ? _ref : '').trim();
|
|
53
|
+
if (!hostId) return res.status(400).json({
|
|
54
|
+
error: 'Missing hostId'
|
|
55
|
+
});
|
|
56
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
57
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
58
|
+
if (!idToken) return res.status(401).json({
|
|
59
|
+
error: 'Unauthenticated'
|
|
60
|
+
});
|
|
61
|
+
let decoded;
|
|
62
|
+
try {
|
|
63
|
+
decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// Only a refused credential is the caller's 401; failing to check it is
|
|
66
|
+
// ours and keeps the dispatcher's 5xx.
|
|
67
|
+
if (!isRefusedIdToken(error)) throw error;
|
|
68
|
+
return res.status(401).json({
|
|
69
|
+
error: 'Unauthenticated'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
var _ref1;
|
|
74
|
+
const membership = await resolveOrgPermissions(decoded.uid, {
|
|
75
|
+
hostId
|
|
76
|
+
});
|
|
77
|
+
if (!membership.orgId || !membership.permissions.publishToMarketplace) {
|
|
78
|
+
return res.status(403).json({
|
|
79
|
+
error: permissionRefusalText('marketplace.publish')
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const orgId = membership.orgId;
|
|
83
|
+
if (decoded['staff'] !== true && !await isServerReleaseFlagOnForOrg('release_site_starters', orgId)) {
|
|
84
|
+
return res.status(403).json({
|
|
85
|
+
error: SITE_STARTERS_UNRELEASED
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
89
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
90
|
+
const hostSnapshot = await hostRef.get();
|
|
91
|
+
if (!hostSnapshot.exists) return res.status(404).json({
|
|
92
|
+
error: 'Unknown site'
|
|
93
|
+
});
|
|
94
|
+
const host = hostSnapshot.data();
|
|
95
|
+
// The routed pages, at most one past the most a starter may have: the
|
|
96
|
+
// window the door walks, enough to say a site is past the limit.
|
|
97
|
+
const routing = host['screens'];
|
|
98
|
+
const routedIds = routing && typeof routing === 'object' && !Array.isArray(routing) ? Object.keys(routing).slice(0, SITE_STARTER_MAX_SCREENS + 1) : [];
|
|
99
|
+
const screens = routedIds.length ? await firestore.getAll(...routedIds.map((id)=>hostRef.collection('screens').doc(id))) : [];
|
|
100
|
+
const pages = starterPublishPages(routing, screens.filter((screen)=>screen.exists).map((screen)=>_extends({}, screen.data(), {
|
|
101
|
+
$id: screen.id
|
|
102
|
+
})));
|
|
103
|
+
const [orgForHost, profile, publisher] = await Promise.all([
|
|
104
|
+
getOrgForHost(hostId),
|
|
105
|
+
firestore.collection(PUBLISHER_PROFILES).doc(orgId).get(),
|
|
106
|
+
resolvePublisherProfile(firestore, orgId)
|
|
107
|
+
]);
|
|
108
|
+
const answer = starterPublishFactsAnswer({
|
|
109
|
+
facts: {
|
|
110
|
+
plan: _extends({
|
|
111
|
+
ready: true,
|
|
112
|
+
locked: !checkEntitlement((_ref1 = orgForHost == null ? void 0 : orgForHost.org) != null ? _ref1 : {}, 'marketplaceSelling')
|
|
113
|
+
}, planLabelGrantingFeature('marketplaceSelling') ? {
|
|
114
|
+
planLabel: planLabelGrantingFeature('marketplaceSelling')
|
|
115
|
+
} : {}),
|
|
116
|
+
profile: profile.exists ? profile.data() : null,
|
|
117
|
+
pages,
|
|
118
|
+
sellsAsPlatform: (publisher == null ? void 0 : publisher.sellsAsPlatform) === true,
|
|
119
|
+
// The apps fix each step on a screen of their own.
|
|
120
|
+
marketplacePath: null,
|
|
121
|
+
pagesPath: null
|
|
122
|
+
},
|
|
123
|
+
builtin: siteStarterBuiltinThemeOf(host)
|
|
124
|
+
});
|
|
125
|
+
return res.status(200).json(answer);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(error);
|
|
128
|
+
return res.status(500).json({
|
|
129
|
+
error: 'Could not read this site'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
export default starterPublishFactsHandler;
|
|
134
|
+
|
|
135
|
+
//# sourceMappingURL=starter-publish-facts.js.map
|