@portalshq/monetization 0.2.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 +182 -0
- package/dist/dispatcher.d.ts +105 -0
- package/dist/dispatcher.js +138 -0
- package/dist/entitlements.d.ts +155 -0
- package/dist/entitlements.js +275 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/outbox-dispatcher.d.ts +16 -0
- package/dist/outbox-dispatcher.js +29 -0
- package/dist/portals-billing.d.ts +137 -0
- package/dist/portals-billing.js +498 -0
- package/dist/postgres-store.d.ts +45 -0
- package/dist/postgres-store.js +276 -0
- package/dist/stripe-client.d.ts +9 -0
- package/dist/stripe-client.js +14 -0
- package/dist/stripe-connect-client.d.ts +45 -0
- package/dist/stripe-connect-client.js +75 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
- package/sql/001_monetization.sql +158 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-charge checkout and Connect management.
|
|
3
|
+
*
|
|
4
|
+
* The tenant is merchant of record: charges are created on the tenant's own
|
|
5
|
+
* account, so the caller supplies a Stripe credential scoped to that account and
|
|
6
|
+
* the platform never holds a key that can move tenant money. The platform's own
|
|
7
|
+
* credential is limited to Connect management.
|
|
8
|
+
*/
|
|
9
|
+
export class Monetization {
|
|
10
|
+
options;
|
|
11
|
+
now;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.now = options.now ?? (() => new Date());
|
|
15
|
+
}
|
|
16
|
+
async getTenantProfile(tenantId) {
|
|
17
|
+
return this.options.store.getTenantProfile(required(tenantId, "tenantId"));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Creates the tenant's connected account via **Accounts v2**
|
|
21
|
+
* (`stripe.v2.core.accounts`), configured as a recipient for destination charges.
|
|
22
|
+
*
|
|
23
|
+
* The v1 `stripe.accounts.create({ type: "express" })` form is deprecated and
|
|
24
|
+
* is not used. `losses_collector: "application"` is required for destination
|
|
25
|
+
* charges: the platform must hold the negative balance so a dispute transfer
|
|
26
|
+
* can be reversed. The cost is that the platform carries dispute liability.
|
|
27
|
+
*/
|
|
28
|
+
async ensureTenantProfile(input) {
|
|
29
|
+
const tenantId = required(input.tenantId, "tenantId");
|
|
30
|
+
const ownerId = required(input.ownerId, "ownerId");
|
|
31
|
+
const existing = await this.options.store.getTenantProfile(tenantId);
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (existing.ownerId !== ownerId)
|
|
34
|
+
throw new Error("Tenant billing owner mismatch");
|
|
35
|
+
return existing;
|
|
36
|
+
}
|
|
37
|
+
const metadata = { tenantId, ownerId };
|
|
38
|
+
const account = await this.options.stripe.v2.core.accounts.create({
|
|
39
|
+
...(input.displayName ? { display_name: input.displayName } : {}),
|
|
40
|
+
...(input.email ? { contact_email: input.email } : {}),
|
|
41
|
+
metadata,
|
|
42
|
+
dashboard: "express",
|
|
43
|
+
// Recipient configuration receives the destination-charge transfer.
|
|
44
|
+
// `stripe_transfers` on `stripe_balance` must be requested explicitly —
|
|
45
|
+
// without it the capability never activates and every checkout is refused.
|
|
46
|
+
// `configuration.merchant` is deliberately not requested: the tenant is
|
|
47
|
+
// not merchant of record and does not accept direct charges.
|
|
48
|
+
configuration: {
|
|
49
|
+
recipient: {
|
|
50
|
+
capabilities: { stripe_balance: { stripe_transfers: { requested: true } } },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
defaults: {
|
|
54
|
+
...(input.defaultCurrency ? { currency: normalizeCurrency(input.defaultCurrency) } : {}),
|
|
55
|
+
// Required for destination charges: the platform must hold the negative
|
|
56
|
+
// balance so a dispute transfer can be reversed. The cost of this model
|
|
57
|
+
// is that the platform carries dispute liability across tenant volume.
|
|
58
|
+
responsibilities: { fees_collector: "application", losses_collector: "application" },
|
|
59
|
+
},
|
|
60
|
+
}, { idempotencyKey: `tenant-account:${tenantId}` });
|
|
61
|
+
const timestamp = this.now().toISOString();
|
|
62
|
+
const profile = {
|
|
63
|
+
tenantId, ownerId, stripeAccountId: account.id,
|
|
64
|
+
transfersStatus: readTransfersStatus(account),
|
|
65
|
+
defaultCurrency: input.defaultCurrency ? normalizeCurrency(input.defaultCurrency) : "usd",
|
|
66
|
+
...(input.country ? { country: input.country } : {}),
|
|
67
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
68
|
+
};
|
|
69
|
+
await this.options.store.saveTenantProfile(profile);
|
|
70
|
+
return profile;
|
|
71
|
+
}
|
|
72
|
+
/** Reads the v2 capability path. Never reads the deprecated v1 boolean fields. */
|
|
73
|
+
async refreshTenantCapabilities(tenantId) {
|
|
74
|
+
const profile = await this.requireReadyProfile(tenantId, false);
|
|
75
|
+
const account = await this.options.stripe.v2.core.accounts.retrieve(profile.stripeAccountId);
|
|
76
|
+
const updated = {
|
|
77
|
+
...profile,
|
|
78
|
+
transfersStatus: readTransfersStatus(account),
|
|
79
|
+
updatedAt: this.now().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
await this.options.store.saveTenantProfile(updated);
|
|
82
|
+
return updated;
|
|
83
|
+
}
|
|
84
|
+
async createConnectOnboardingLink(tenantId, refreshUrl, returnUrl) {
|
|
85
|
+
const profile = await this.requireReadyProfile(tenantId, false);
|
|
86
|
+
const accountLink = await this.options.stripe.v2.core.accountLinks.create({
|
|
87
|
+
account: profile.stripeAccountId,
|
|
88
|
+
use_case: {
|
|
89
|
+
type: "account_onboarding",
|
|
90
|
+
account_onboarding: {
|
|
91
|
+
configurations: ["merchant"],
|
|
92
|
+
refresh_url: validHttpUrl(refreshUrl, "refreshUrl"),
|
|
93
|
+
return_url: validHttpUrl(returnUrl, "returnUrl"),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return { url: accountLink.url };
|
|
98
|
+
}
|
|
99
|
+
async tenantClient() {
|
|
100
|
+
return this.options.stripe;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Creates a destination-charge Checkout Session on the **platform** account,
|
|
104
|
+
* transferring to the tenant's connected account on success.
|
|
105
|
+
*
|
|
106
|
+
* `customerId` is the end user's platform-account `Customer`. Supply it to
|
|
107
|
+
* reuse payment details already saved for another tenant; omit it and Stripe
|
|
108
|
+
* creates a Customer for this session only. This is the mechanism behind
|
|
109
|
+
* "provide payment once, use on any tenant" — a `PaymentMethod` cannot move
|
|
110
|
+
* between connected accounts, so the Customer has to live on the platform.
|
|
111
|
+
*
|
|
112
|
+
* `application_fee_amount` comes from the server-side catalog;
|
|
113
|
+
* `CreateCheckoutInput` has no fee field, so no caller can influence it.
|
|
114
|
+
*
|
|
115
|
+
* `integration_identifier` labels the session in the Dashboard for tracking
|
|
116
|
+
* and comparing checkout flows.
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* The platform-account `Customer` for this consumer, creating it on first use.
|
|
120
|
+
*
|
|
121
|
+
* Because the platform holds the customer rather than each tenant, one saved
|
|
122
|
+
* payment method pays for purchases across every tenant the consumer buys
|
|
123
|
+
* from. A duplicate is possible if two requests race before either commits, in
|
|
124
|
+
* which case the last write wins and the superseded Customer is orphaned in
|
|
125
|
+
* Stripe; that is a rare, harmless leak rather than a wrong charge, so it is
|
|
126
|
+
* not worth a lock.
|
|
127
|
+
*/
|
|
128
|
+
async ensureBillingCustomer(buyerId) {
|
|
129
|
+
const existing = await this.options.store.getBillingCustomer(buyerId);
|
|
130
|
+
if (existing)
|
|
131
|
+
return { id: existing };
|
|
132
|
+
const customer = await this.options.stripe.customers.create({
|
|
133
|
+
metadata: { buyerId },
|
|
134
|
+
});
|
|
135
|
+
await this.options.store.saveBillingCustomer(buyerId, customer.id, this.now().toISOString());
|
|
136
|
+
return { id: customer.id };
|
|
137
|
+
}
|
|
138
|
+
async createCheckout(input) {
|
|
139
|
+
const profile = await this.requireReadyProfile(input.tenantId, true);
|
|
140
|
+
const item = normalizeCatalogItem(this.options.catalog.resolve(required(input.productKey, "productKey")));
|
|
141
|
+
const purchaseId = required(input.purchaseId, "purchaseId");
|
|
142
|
+
const buyerId = required(input.buyerId, "buyerId");
|
|
143
|
+
const channelId = required(input.channelId, "channelId");
|
|
144
|
+
const stripe = await this.tenantClient();
|
|
145
|
+
const existing = await this.options.store.getPurchase(purchaseId);
|
|
146
|
+
if (existing) {
|
|
147
|
+
assertSamePurchase(existing, input.tenantId, channelId, buyerId, item);
|
|
148
|
+
if (existing.stripeCheckoutSessionId) {
|
|
149
|
+
const session = await stripe.checkout.sessions.retrieve(existing.stripeCheckoutSessionId);
|
|
150
|
+
return { purchase: existing, sessionId: session.id, url: session.url };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const now = this.now().toISOString();
|
|
154
|
+
const purchase = existing ?? {
|
|
155
|
+
id: purchaseId, tenantId: profile.tenantId, channelId,
|
|
156
|
+
...(input.sessionId ? { sessionId: required(input.sessionId, "sessionId") } : {}),
|
|
157
|
+
buyerId,
|
|
158
|
+
...(input.customerId ? { customerId: required(input.customerId, "customerId") } : {}),
|
|
159
|
+
productKey: item.key, kind: item.purchaseKind, amount: item.unitAmount, currency: item.currency,
|
|
160
|
+
platformFeeAmount: item.platformFeeAmount, state: "pending", refundedAmount: 0,
|
|
161
|
+
createdAt: now, updatedAt: now,
|
|
162
|
+
};
|
|
163
|
+
await this.options.store.createPurchase(purchase);
|
|
164
|
+
const metadata = { purchaseId: purchase.id, tenantId: purchase.tenantId, channelId: purchase.channelId, purchaseKind: purchase.kind, schemaVersion: "3" };
|
|
165
|
+
const lineItem = item.stripePriceId
|
|
166
|
+
? { price: item.stripePriceId, quantity: 1 }
|
|
167
|
+
: { price_data: { currency: item.currency, unit_amount: item.unitAmount, product_data: { name: item.name } }, quantity: 1 };
|
|
168
|
+
const session = await stripe.checkout.sessions.create({
|
|
169
|
+
mode: "payment",
|
|
170
|
+
// The end user's Customer lives on the platform account, so a saved
|
|
171
|
+
// payment method is reusable across every tenant they pay.
|
|
172
|
+
...(input.customerId ? { customer: required(input.customerId, "customerId") } : {}),
|
|
173
|
+
line_items: [lineItem],
|
|
174
|
+
success_url: validHttpUrl(input.successUrl, "successUrl"),
|
|
175
|
+
cancel_url: validHttpUrl(input.cancelUrl, "cancelUrl"),
|
|
176
|
+
client_reference_id: purchase.id,
|
|
177
|
+
metadata,
|
|
178
|
+
integration_identifier: `portalshq-monetization-${randomSuffix()}`,
|
|
179
|
+
payment_intent_data: {
|
|
180
|
+
metadata,
|
|
181
|
+
// Destination charge: funds move to the tenant automatically on
|
|
182
|
+
// success. The fee is read from the server-side catalog, and
|
|
183
|
+
// `CreateCheckoutInput` has no fee field, so no caller can influence it.
|
|
184
|
+
transfer_data: { destination: profile.stripeAccountId },
|
|
185
|
+
...(item.platformFeeAmount > 0 ? { application_fee_amount: item.platformFeeAmount } : {}),
|
|
186
|
+
},
|
|
187
|
+
}, { idempotencyKey: `checkout:${purchase.id}` });
|
|
188
|
+
const updated = { ...purchase, state: "checkout_created", stripeCheckoutSessionId: session.id, updatedAt: this.now().toISOString() };
|
|
189
|
+
// createPurchase implementations must be idempotent upserts for the same purchase id.
|
|
190
|
+
await this.options.store.createPurchase(updated);
|
|
191
|
+
return { purchase: updated, sessionId: session.id, url: session.url };
|
|
192
|
+
}
|
|
193
|
+
async reportMeterEvent(tenantId, customerId, eventName, value, identifier) {
|
|
194
|
+
const profile = await this.requireReadyProfile(tenantId, false);
|
|
195
|
+
const stripe = await this.tenantClient();
|
|
196
|
+
if (!Number.isFinite(value) || value < 0)
|
|
197
|
+
throw new TypeError("meter value must be non-negative");
|
|
198
|
+
await stripe.billing.meterEvents.create({
|
|
199
|
+
event_name: required(eventName, "eventName"),
|
|
200
|
+
identifier: required(identifier, "identifier"),
|
|
201
|
+
payload: { stripe_customer_id: required(customerId, "customerId"), value: String(value) },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Customer and portal session both live on the tenant's account. */
|
|
205
|
+
async createCustomerPortalSession(tenantId, customerId, returnUrl) {
|
|
206
|
+
const profile = await this.requireReadyProfile(tenantId, false);
|
|
207
|
+
const stripe = await this.tenantClient();
|
|
208
|
+
return stripe.billingPortal.sessions.create({
|
|
209
|
+
customer: required(customerId, "customerId"),
|
|
210
|
+
return_url: validHttpUrl(returnUrl, "returnUrl"),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Checks what Stripe actually recorded against the server-side catalog.
|
|
215
|
+
*
|
|
216
|
+
* The platform sets the fee, so this is defense in depth rather than the
|
|
217
|
+
* primary control: it catches a misconfigured catalog, a replayed session, or
|
|
218
|
+
* a manually created charge. Call it before granting entitlements or paying
|
|
219
|
+
* out.
|
|
220
|
+
*/
|
|
221
|
+
async verifySettlement(purchaseId, actualAmount, actualFeeAmount) {
|
|
222
|
+
const purchase = await this.options.store.getPurchase(required(purchaseId, "purchaseId"));
|
|
223
|
+
if (!purchase)
|
|
224
|
+
return { ok: false, reason: "amount_mismatch", expected: 0, actual: actualAmount };
|
|
225
|
+
if (actualAmount !== purchase.amount) {
|
|
226
|
+
return { ok: false, reason: "amount_mismatch", expected: purchase.amount, actual: actualAmount };
|
|
227
|
+
}
|
|
228
|
+
const actualFee = actualFeeAmount ?? 0;
|
|
229
|
+
if (actualFee !== purchase.platformFeeAmount) {
|
|
230
|
+
return { ok: false, reason: "fee_mismatch", expected: purchase.platformFeeAmount, actual: actualFee };
|
|
231
|
+
}
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
async refundPurchase(input) {
|
|
235
|
+
const profile = await this.requireReadyProfile(required(input.tenantId, "tenantId"), false);
|
|
236
|
+
const stripe = await this.tenantClient();
|
|
237
|
+
const purchase = await this.options.store.getPurchase(required(input.purchaseId, "purchaseId"));
|
|
238
|
+
if (!purchase?.stripePaymentIntentId)
|
|
239
|
+
throw new Error("Settled purchase with PaymentIntent is required");
|
|
240
|
+
const remaining = purchase.amount - purchase.refundedAmount;
|
|
241
|
+
const amount = input.amount ?? remaining;
|
|
242
|
+
if (!Number.isInteger(amount) || amount < 1 || amount > remaining)
|
|
243
|
+
throw new TypeError("refund amount exceeds refundable amount");
|
|
244
|
+
const item = normalizeCatalogItem(this.options.catalog.resolve(purchase.productKey));
|
|
245
|
+
return stripe.refunds.create({
|
|
246
|
+
payment_intent: purchase.stripePaymentIntentId,
|
|
247
|
+
amount,
|
|
248
|
+
reverse_transfer: true,
|
|
249
|
+
refund_application_fee: item.refundApplicationFee ?? true,
|
|
250
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
251
|
+
metadata: { purchaseId: purchase.id, tenantId: purchase.tenantId, channelId: purchase.channelId },
|
|
252
|
+
}, { idempotencyKey: `refund:${purchase.id}:${purchase.refundedAmount}:${amount}` });
|
|
253
|
+
}
|
|
254
|
+
async handleWebhook(rawBody, signature, secret) {
|
|
255
|
+
const event = this.options.stripe.webhooks.constructEvent(rawBody, required(signature, "signature"), required(secret, "webhook secret"));
|
|
256
|
+
// A Connect transfer reversal moves money, so it must not run inside the
|
|
257
|
+
// database transaction. Doing it first, under a stable idempotency key,
|
|
258
|
+
// means a retry after a failed commit re-issues the same key and Stripe
|
|
259
|
+
// returns the original reversal instead of clawing back the funds twice.
|
|
260
|
+
const reversal = event.type === "charge.dispute.created" ? await this.maybeReverseTransfer(event) : undefined;
|
|
261
|
+
const status = await this.options.store.processStripeEvent(event, async (transaction) => this.applyEvent(transaction, event, reversal));
|
|
262
|
+
return { eventId: event.id, status };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Reads the purchase and reverses the Connect transfer when policy allows.
|
|
266
|
+
* Runs outside the webhook transaction; the resulting ledger entry is written
|
|
267
|
+
* by `applyEvent` once the transaction commits.
|
|
268
|
+
*/
|
|
269
|
+
async maybeReverseTransfer(event) {
|
|
270
|
+
const dispute = event.data.object;
|
|
271
|
+
const chargeId = typeof dispute.charge === "object" ? dispute.charge.id : dispute.charge;
|
|
272
|
+
const inline = typeof dispute.charge === "object" ? dispute.charge : undefined;
|
|
273
|
+
const purchase = await this.options.store.findPurchaseByStripeReference(chargeId)
|
|
274
|
+
?? (inline?.metadata?.purchaseId ? await this.options.store.getPurchase(inline.metadata.purchaseId) : undefined);
|
|
275
|
+
if (!purchase?.stripeTransferId)
|
|
276
|
+
return undefined;
|
|
277
|
+
if (!await this.options.reverseTransferForDispute?.(purchase, dispute))
|
|
278
|
+
return undefined;
|
|
279
|
+
const amount = Math.min(dispute.amount, Math.max(0, purchase.amount - purchase.refundedAmount));
|
|
280
|
+
if (amount <= 0)
|
|
281
|
+
return undefined;
|
|
282
|
+
const reversal = await this.options.stripe.transfers.createReversal(purchase.stripeTransferId, { amount, metadata: { purchaseId: purchase.id, disputeId: dispute.id } }, { idempotencyKey: `dispute-reversal:${purchase.id}:${dispute.id}` });
|
|
283
|
+
return { reversalId: reversal.id, amount, disputeId: dispute.id };
|
|
284
|
+
}
|
|
285
|
+
async applyEvent(transaction, event, reversal) {
|
|
286
|
+
if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
|
|
287
|
+
const session = event.data.object;
|
|
288
|
+
if (session.payment_status !== "paid")
|
|
289
|
+
return;
|
|
290
|
+
const purchaseId = session.metadata?.purchaseId ?? session.client_reference_id;
|
|
291
|
+
if (!purchaseId)
|
|
292
|
+
return;
|
|
293
|
+
const purchase = await transaction.getPurchase(purchaseId);
|
|
294
|
+
if (!purchase || purchase.state === "settled")
|
|
295
|
+
return;
|
|
296
|
+
const paymentIntentId = stripeId(session.payment_intent);
|
|
297
|
+
let chargeId;
|
|
298
|
+
let transferId;
|
|
299
|
+
let applicationFeeId;
|
|
300
|
+
let stripeFee;
|
|
301
|
+
let net;
|
|
302
|
+
if (paymentIntentId) {
|
|
303
|
+
// The charge is on the platform account, so the platform's own
|
|
304
|
+
// credential reads it. `transfer` is expanded to record the Connect
|
|
305
|
+
// transfer, which is what a dispute reversal needs.
|
|
306
|
+
const intent = await this.options.stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["latest_charge.balance_transaction", "latest_charge.transfer", "latest_charge.application_fee"] });
|
|
307
|
+
const charge = typeof intent.latest_charge === "object" ? intent.latest_charge : undefined;
|
|
308
|
+
chargeId = charge?.id;
|
|
309
|
+
transferId = stripeId(charge?.transfer);
|
|
310
|
+
applicationFeeId = stripeId(charge?.application_fee);
|
|
311
|
+
const balance = charge && typeof charge.balance_transaction === "object" ? charge.balance_transaction : undefined;
|
|
312
|
+
stripeFee = balance?.fee;
|
|
313
|
+
net = balance?.net;
|
|
314
|
+
// The amount is ours: the Checkout Session was built from the catalog.
|
|
315
|
+
// A mismatch means the session was tampered with or replayed, so the
|
|
316
|
+
// purchase is not settled and nothing is granted.
|
|
317
|
+
if (typeof charge?.amount_captured === "number" && charge.amount_captured > 0 && charge.amount_captured !== purchase.amount) {
|
|
318
|
+
await transaction.savePurchase({ ...purchase, state: "failed", updatedAt: this.now().toISOString() });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const settledAt = this.now().toISOString();
|
|
323
|
+
const updated = {
|
|
324
|
+
...purchase, state: "settled", stripeCheckoutSessionId: session.id, settledAt,
|
|
325
|
+
...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
|
|
326
|
+
...(chargeId ? { stripeChargeId: chargeId } : {}),
|
|
327
|
+
...(transferId ? { stripeTransferId: transferId } : {}),
|
|
328
|
+
...(applicationFeeId ? { stripeApplicationFeeId: applicationFeeId } : {}),
|
|
329
|
+
updatedAt: settledAt,
|
|
330
|
+
};
|
|
331
|
+
await transaction.savePurchase(updated);
|
|
332
|
+
await transaction.appendLedger(this.ledger(event, updated, "settlement", updated.amount, stripeFee, net));
|
|
333
|
+
await transaction.appendOutbox(this.outbox(updated, "billing.purchase_settled"));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (event.type === "checkout.session.async_payment_failed") {
|
|
337
|
+
const session = event.data.object;
|
|
338
|
+
const id = session.metadata?.purchaseId ?? session.client_reference_id;
|
|
339
|
+
if (!id)
|
|
340
|
+
return;
|
|
341
|
+
const purchase = await transaction.getPurchase(id);
|
|
342
|
+
if (purchase && purchase.state !== "settled")
|
|
343
|
+
await transaction.savePurchase({ ...purchase, state: "failed", updatedAt: this.now().toISOString() });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (event.type === "charge.refunded") {
|
|
347
|
+
const charge = event.data.object;
|
|
348
|
+
const purchase = await this.findPurchaseForCharge(transaction, charge);
|
|
349
|
+
if (!purchase)
|
|
350
|
+
return;
|
|
351
|
+
const refundedAmount = Math.min(purchase.amount, charge.amount_refunded);
|
|
352
|
+
const refundDelta = Math.max(0, refundedAmount - purchase.refundedAmount);
|
|
353
|
+
if (refundDelta === 0)
|
|
354
|
+
return;
|
|
355
|
+
const updated = {
|
|
356
|
+
...purchase,
|
|
357
|
+
refundedAmount,
|
|
358
|
+
stripeChargeId: charge.id,
|
|
359
|
+
...(stripeId(charge.payment_intent) ? { stripePaymentIntentId: stripeId(charge.payment_intent) } : {}),
|
|
360
|
+
...(stripeId(charge.application_fee) ? { stripeApplicationFeeId: stripeId(charge.application_fee) } : {}),
|
|
361
|
+
state: refundedAmount >= purchase.amount ? "refunded" : "partially_refunded",
|
|
362
|
+
updatedAt: this.now().toISOString(),
|
|
363
|
+
};
|
|
364
|
+
await transaction.savePurchase(updated);
|
|
365
|
+
await transaction.appendLedger(this.ledger(event, updated, "refund", refundDelta));
|
|
366
|
+
await transaction.appendOutbox(this.outbox(updated, "billing.purchase_refunded"));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (event.type === "charge.dispute.created") {
|
|
370
|
+
const dispute = event.data.object;
|
|
371
|
+
// Resolve the purchase from the charge id in the payload. The charge is
|
|
372
|
+
// recorded on the purchase, so this normally needs no API call at all.
|
|
373
|
+
// When it cannot be resolved there is no safe way to continue: fetching
|
|
374
|
+
// the charge would require guessing which tenant's credential to use, and
|
|
375
|
+
// the Connect-management credential must never read a tenant's billing
|
|
376
|
+
// details. The event is skipped rather than read at the wrong scope.
|
|
377
|
+
const chargeId = typeof dispute.charge === "object" ? dispute.charge.id : dispute.charge;
|
|
378
|
+
const inline = typeof dispute.charge === "object" ? dispute.charge : undefined;
|
|
379
|
+
let purchase = await transaction.findPurchaseByStripeReference(chargeId);
|
|
380
|
+
if (!purchase && inline?.metadata?.purchaseId) {
|
|
381
|
+
purchase = await transaction.getPurchase(inline.metadata.purchaseId);
|
|
382
|
+
}
|
|
383
|
+
if (!purchase)
|
|
384
|
+
return;
|
|
385
|
+
const charge = inline ?? { id: chargeId, amount_refunded: 0, metadata: {} };
|
|
386
|
+
const updated = { ...purchase, state: "disputed", updatedAt: this.now().toISOString() };
|
|
387
|
+
await transaction.savePurchase(updated);
|
|
388
|
+
await transaction.appendLedger(this.ledger(event, updated, "dispute", dispute.amount));
|
|
389
|
+
// The reversal already happened, outside this transaction, under a
|
|
390
|
+
// stable idempotency key. Recording it here keeps the ledger and the
|
|
391
|
+
// transfer in agreement.
|
|
392
|
+
if (reversal) {
|
|
393
|
+
const entry = this.ledger(event, updated, "transfer_reversal", reversal.amount);
|
|
394
|
+
entry.metadata = { stripeTransferReversalId: reversal.reversalId, stripeDisputeId: reversal.disputeId };
|
|
395
|
+
await transaction.appendLedger(entry);
|
|
396
|
+
}
|
|
397
|
+
await transaction.appendOutbox(this.outbox(updated, "billing.purchase_disputed"));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (event.type === "account.updated") {
|
|
401
|
+
const account = event.data.object;
|
|
402
|
+
const profile = await this.options.store.findTenantProfileByAccount(account.id);
|
|
403
|
+
if (profile) {
|
|
404
|
+
// v1 emitted the deprecated booleans on this event, so capability state
|
|
405
|
+
// is re-read from the v2 path rather than trusted from the payload.
|
|
406
|
+
const refreshed = await this.refreshTenantCapabilities(profile.tenantId);
|
|
407
|
+
if (refreshed.transfersStatus === "active")
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
ledger(event, purchase, kind, amount, stripeFee, net) {
|
|
413
|
+
return { id: this.options.ids.next(), purchaseId: purchase.id, stripeEventId: event.id, kind, amount, currency: purchase.currency, ...(stripeFee === undefined ? {} : { stripeFee }), ...(net === undefined ? {} : { net }), createdAt: this.now().toISOString() };
|
|
414
|
+
}
|
|
415
|
+
async findPurchaseForCharge(transaction, charge) {
|
|
416
|
+
const byReference = await transaction.findPurchaseByStripeReference(charge.id);
|
|
417
|
+
if (byReference)
|
|
418
|
+
return byReference;
|
|
419
|
+
const purchaseId = charge.metadata?.purchaseId;
|
|
420
|
+
return purchaseId ? transaction.getPurchase(purchaseId) : undefined;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Settlement facts. Every field an entitlement rule can match on is present
|
|
424
|
+
* here, so a rule is evaluable from the outbox alone without re-reading the
|
|
425
|
+
* purchase. `schemaVersion` was bumped to 2 because the payload gained
|
|
426
|
+
* `tenantId`, `sessionId`, `productKey`, and `settledAt`.
|
|
427
|
+
*/
|
|
428
|
+
outbox(purchase, type) {
|
|
429
|
+
const facts = {
|
|
430
|
+
purchaseId: purchase.id,
|
|
431
|
+
tenantId: purchase.tenantId,
|
|
432
|
+
channelId: purchase.channelId,
|
|
433
|
+
sessionId: purchase.sessionId ?? null,
|
|
434
|
+
buyerId: purchase.buyerId,
|
|
435
|
+
kind: purchase.kind,
|
|
436
|
+
productKey: purchase.productKey,
|
|
437
|
+
amount: purchase.amount,
|
|
438
|
+
currency: purchase.currency,
|
|
439
|
+
settledAt: purchase.settledAt ?? purchase.updatedAt,
|
|
440
|
+
};
|
|
441
|
+
return {
|
|
442
|
+
id: this.options.ids.next(), type, aggregateId: purchase.id,
|
|
443
|
+
payload: { ...facts, state: purchase.state, refundedAmount: purchase.refundedAmount },
|
|
444
|
+
createdAt: this.now().toISOString(), attempts: 0,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async requireReadyProfile(tenantId, requireTransfers) {
|
|
448
|
+
const profile = await this.options.store.getTenantProfile(required(tenantId, "tenantId"));
|
|
449
|
+
if (!profile)
|
|
450
|
+
throw new Error("Tenant billing profile not found");
|
|
451
|
+
if (requireTransfers && profile.transfersStatus !== "active") {
|
|
452
|
+
throw new Error("Tenant account is not active for transfers");
|
|
453
|
+
}
|
|
454
|
+
return profile;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** v2 capability path. The deprecated v1 booleans are deliberately not read. */
|
|
458
|
+
function readTransfersStatus(account) {
|
|
459
|
+
const status = account?.configuration?.recipient?.capabilities?.stripe_balance?.stripe_transfers?.status;
|
|
460
|
+
if (status === "active")
|
|
461
|
+
return "active";
|
|
462
|
+
if (status === "inactive")
|
|
463
|
+
return "inactive";
|
|
464
|
+
return "pending";
|
|
465
|
+
}
|
|
466
|
+
/** 8 random letters, the suffix the Dashboard expects on integration_identifier. */
|
|
467
|
+
function randomSuffix() {
|
|
468
|
+
let out = "";
|
|
469
|
+
for (let i = 0; i < 8; i += 1) {
|
|
470
|
+
out += LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
475
|
+
function normalizeCatalogItem(item) {
|
|
476
|
+
required(item.key, "catalog key");
|
|
477
|
+
required(item.name, "catalog name");
|
|
478
|
+
if (!Number.isInteger(item.unitAmount) || item.unitAmount < 1)
|
|
479
|
+
throw new TypeError("unitAmount must be a positive integer");
|
|
480
|
+
if (!Number.isInteger(item.platformFeeAmount) || item.platformFeeAmount < 0 || item.platformFeeAmount > item.unitAmount)
|
|
481
|
+
throw new TypeError("platformFeeAmount is invalid");
|
|
482
|
+
item.currency = normalizeCurrency(item.currency);
|
|
483
|
+
return item;
|
|
484
|
+
}
|
|
485
|
+
function required(value, name) { const normalized = value.trim(); if (!normalized)
|
|
486
|
+
throw new TypeError(`${name} is required`); return normalized; }
|
|
487
|
+
function normalizeCurrency(value) { const currency = value.trim().toLowerCase(); if (!/^[a-z]{3}$/.test(currency))
|
|
488
|
+
throw new TypeError("currency must be a three-letter ISO code"); return currency; }
|
|
489
|
+
function validHttpUrl(value, name) { const url = new URL(value); if (!/^https?:$/.test(url.protocol) || url.username || url.password)
|
|
490
|
+
throw new TypeError(`${name} must be an http(s) URL without credentials`); return url.toString(); }
|
|
491
|
+
function stripeId(value) { return typeof value === "string" ? value : value?.id; }
|
|
492
|
+
function assertSamePurchase(purchase, tenantId, channelId, buyerId, item) {
|
|
493
|
+
if (purchase.tenantId !== tenantId || purchase.channelId !== channelId || purchase.buyerId !== buyerId
|
|
494
|
+
|| purchase.productKey !== item.key || purchase.amount !== item.unitAmount || purchase.currency !== item.currency
|
|
495
|
+
|| purchase.platformFeeAmount !== item.platformFeeAmount) {
|
|
496
|
+
throw new Error("purchaseId is already associated with a different purchase");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import type { BillingOutboxEvent, BillingStore, BillingTransaction, Purchase, TenantBillingProfile } from "./types.js";
|
|
4
|
+
import type { EntitlementGrant, EntitlementRule, EntitlementScope, EntitlementStore } from "./entitlements.js";
|
|
5
|
+
/** Durable PostgreSQL adapter. Apply sql/001_monetization.sql before use. */
|
|
6
|
+
export declare class PostgresBillingStore implements BillingStore {
|
|
7
|
+
private readonly pool;
|
|
8
|
+
constructor(pool: Pool);
|
|
9
|
+
getTenantProfile(tenantId: string): Promise<TenantBillingProfile | undefined>;
|
|
10
|
+
findTenantProfileByAccount(accountId: string): Promise<TenantBillingProfile | undefined>;
|
|
11
|
+
getBillingCustomer(buyerId: string): Promise<string | undefined>;
|
|
12
|
+
saveBillingCustomer(buyerId: string, stripeCustomerId: string, createdAt: string): Promise<void>;
|
|
13
|
+
saveTenantProfile(profile: TenantBillingProfile): Promise<void>;
|
|
14
|
+
createPurchase(purchase: Purchase): Promise<Purchase>;
|
|
15
|
+
getPurchase(id: string): Promise<Purchase | undefined>;
|
|
16
|
+
findPurchaseByStripeReference(reference: string): Promise<Purchase | undefined>;
|
|
17
|
+
processStripeEvent(event: Stripe.Event, apply: (transaction: BillingTransaction) => Promise<void>): Promise<"processed" | "duplicate">;
|
|
18
|
+
claimOutbox(limit: number): Promise<readonly BillingOutboxEvent[]>;
|
|
19
|
+
completeOutbox(id: string): Promise<void>;
|
|
20
|
+
failOutbox(id: string, error: string): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Parks the event rather than deleting it. `last_error` is kept and the claim
|
|
23
|
+
* is released, so an operator can see what failed and replay it once fixed.
|
|
24
|
+
*/
|
|
25
|
+
markOutboxDead(id: string, error: string): Promise<void>;
|
|
26
|
+
listDeadOutbox(limit: number): Promise<readonly BillingOutboxEvent[]>;
|
|
27
|
+
requeueDeadOutbox(limit: number): Promise<number>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Durable entitlement store. Grants are keyed `(rule_id, purchase_id)` so a
|
|
31
|
+
* redelivered settlement outbox event cannot double-grant, and `consume` takes
|
|
32
|
+
* a row lock per grant so two concurrent consumers cannot both spend the last
|
|
33
|
+
* unit.
|
|
34
|
+
*/
|
|
35
|
+
export declare class PostgresEntitlementStore implements EntitlementStore {
|
|
36
|
+
private readonly pool;
|
|
37
|
+
constructor(pool: Pool);
|
|
38
|
+
saveRule(rule: EntitlementRule): Promise<void>;
|
|
39
|
+
getRule(id: string): Promise<EntitlementRule | undefined>;
|
|
40
|
+
listRules(tenantId: string): Promise<readonly EntitlementRule[]>;
|
|
41
|
+
grant(grants: readonly EntitlementGrant[]): Promise<void>;
|
|
42
|
+
consume(consumerId: string, kind: string, site: EntitlementScope, n: number): Promise<boolean>;
|
|
43
|
+
remaining(consumerId: string, kind: string, site: EntitlementScope): Promise<number>;
|
|
44
|
+
revokeForPurchase(purchaseId: string): Promise<void>;
|
|
45
|
+
}
|