@opengeni/api-router 1.1.0 → 1.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.
@@ -8,12 +8,15 @@ import {
8
8
  OrganizationUsageWorkspacePageQuery,
9
9
  type AccessContext,
10
10
  type Permission,
11
+ type PromotionalCreditScope,
11
12
  } from "@opengeni/contracts";
12
13
  import { OrganizationModelUsageQuery } from "@opengeni/contracts/organization-model-usage";
13
- import { configuredEntitlements } from "@opengeni/config";
14
+ import { configuredEntitlements, promotionalCreditScope } from "@opengeni/config";
15
+ import { creditScopeMetadata, creditScopeFromMetadata } from "../credit-promotion-snapshot";
14
16
  import {
15
17
  applyCreditLedgerEntry,
16
18
  getBillingBalance,
19
+ readCreditPromotionPolicy,
17
20
  getBillingCustomer,
18
21
  getCreditLedgerEntry,
19
22
  hasCreditLedgerEntry,
@@ -23,6 +26,7 @@ import {
23
26
  getOrganizationUsageSummary,
24
27
  getOrganizationUsageWorkspacePage,
25
28
  withSessionRlsActorContext,
29
+ withRlsContext,
26
30
  getManagedAccount,
27
31
  markStripeWebhookProcessed,
28
32
  recordStripeWebhookEvent,
@@ -134,6 +138,15 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
134
138
  const promotion = body.promotionCode
135
139
  ? await resolveCheckoutPromotionCode(stripe, body.promotionCode)
136
140
  : null;
141
+ const promotionalScope = promotion
142
+ ? promotionalCreditScope(
143
+ {
144
+ creditPromotionPolicy:
145
+ (await readCreditPromotionPolicy(deps.db)) ?? deps.settings.creditPromotionPolicy,
146
+ },
147
+ promotion.couponId,
148
+ )
149
+ : undefined;
137
150
  const amountCents =
138
151
  body.amountUsd !== undefined
139
152
  ? usdToCents(body.amountUsd)
@@ -150,6 +163,15 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
150
163
  message: "Credits must be between $5 and $10,000.",
151
164
  });
152
165
  }
166
+ if (
167
+ promotionalScope &&
168
+ (!promotion?.amountOffCents || amountCents !== promotion.amountOffCents)
169
+ ) {
170
+ throw new HTTPException(422, {
171
+ message:
172
+ "Redeem this offer for its exact credit amount. Buy additional credits separately.",
173
+ });
174
+ }
153
175
  const amountMicros = centsToMicros(amountCents);
154
176
  const customerId = await getOrCreateStripeCustomer(deps, stripe, context, accountId);
155
177
  const idempotencyKey = `checkout:${accountId}:${amountMicros}:${crypto.randomUUID()}`;
@@ -167,9 +189,11 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
167
189
  successUrl: body.successUrl,
168
190
  cancelUrl: body.cancelUrl,
169
191
  idempotencyKey,
192
+ promotionalScope,
170
193
  ...(promotion
171
194
  ? {
172
195
  promotionCodeId: promotion.id,
196
+ couponId: promotion.couponId,
173
197
  fullyDiscounted:
174
198
  promotion.percentOff === 100 || (promotion.amountOffCents ?? 0) >= amountCents,
175
199
  }
@@ -193,6 +217,7 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
193
217
  checkoutSessionId: session.id,
194
218
  url: session.url,
195
219
  amountUsd: amountCents / 100,
220
+ promotionalScope,
196
221
  }),
197
222
  );
198
223
  });
@@ -241,6 +266,7 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
241
266
  state: entry ? "granted" : "pending",
242
267
  amountMicros: entry?.amountMicros ?? credit.amountMicros,
243
268
  currency: "usd",
269
+ promotionalScope: creditScopeFromMetadata(session.metadata),
244
270
  free: entry
245
271
  ? entry.sourceType === "stripe_checkout_coupon"
246
272
  : isFreeCouponCheckout(session),
@@ -340,6 +366,8 @@ export function stripeCheckoutSessionCreateParams(input: {
340
366
  idempotencyKey: string;
341
367
  /** Apply this promotion code up front instead of letting Stripe ask for one. */
342
368
  promotionCodeId?: string | undefined;
369
+ couponId?: string | undefined;
370
+ promotionalScope?: PromotionalCreditScope | undefined;
343
371
  /**
344
372
  * The applied code covers the whole package, so the total is $0. Nothing is
345
373
  * taxable, so Checkout skips tax and with it the billing address: the
@@ -366,7 +394,7 @@ export function stripeCheckoutSessionCreateParams(input: {
366
394
  // Stripe takes either a code field on its page or one discount up front.
367
395
  ...(input.promotionCodeId
368
396
  ? { discounts: [{ promotion_code: input.promotionCodeId }] }
369
- : { allow_promotion_codes: true }),
397
+ : { allow_promotion_codes: false }),
370
398
  customer: input.customerId,
371
399
  customer_update: {
372
400
  address: "auto",
@@ -397,6 +425,8 @@ export function stripeCheckoutSessionCreateParams(input: {
397
425
  },
398
426
  ],
399
427
  metadata: {
428
+ ...creditScopeMetadata(input.promotionalScope),
429
+ ...(input.couponId ? { opengeni_credit_offer_id: input.couponId } : {}),
400
430
  opengeni_account_id: input.accountId,
401
431
  opengeni_credit_amount_usd: (input.amountCents / 100).toFixed(2),
402
432
  opengeni_credit_micros: String(input.amountMicros),
@@ -607,6 +637,10 @@ async function grantCheckoutSessionCredits(
607
637
  }
608
638
  const credit = decision.credit;
609
639
  const freeCouponCheckout = isFreeCouponCheckout(session);
640
+ const promotionalScope = creditScopeFromMetadata(session.metadata);
641
+ if (promotionalScope && !freeCouponCheckout) {
642
+ throw new Error("Scoped promotional credits require a fully discounted checkout");
643
+ }
610
644
  if (!(await getManagedAccount(deps.db, credit.accountId))) {
611
645
  // Another OpenGeni deployment sharing the Stripe account (or an account
612
646
  // removed before a delayed payment settled). Retrying cannot succeed, so
@@ -636,10 +670,13 @@ async function grantCheckoutSessionCredits(
636
670
  accountId: credit.accountId,
637
671
  type: freeCouponCheckout ? "grant" : "credit_topup",
638
672
  amountMicros: credit.amountMicros,
673
+ eligibleModelIds: promotionalScope?.eligibleModelIds,
639
674
  sourceType: freeCouponCheckout ? "stripe_checkout_coupon" : "stripe_checkout_session",
640
675
  sourceId: session.id,
641
676
  idempotencyKey: credit.idempotencyKey,
642
677
  metadata: {
678
+ ...(promotionalScope ? { creditOfferLabel: promotionalScope.label } : {}),
679
+ creditOfferId: session.metadata?.opengeni_credit_offer_id ?? null,
643
680
  stripeEventId: source.stripeEventId,
644
681
  stripePaymentIntentId:
645
682
  typeof session.payment_intent === "string"
@@ -665,7 +702,12 @@ async function grantCheckoutSessionCredits(
665
702
  async function resolveCheckoutPromotionCode(
666
703
  stripe: Stripe,
667
704
  code: string,
668
- ): Promise<{ id: string; amountOffCents: number | null; percentOff: number | null }> {
705
+ ): Promise<{
706
+ id: string;
707
+ couponId: string;
708
+ amountOffCents: number | null;
709
+ percentOff: number | null;
710
+ }> {
669
711
  const listed = await stripe.promotionCodes.list({
670
712
  code,
671
713
  active: true,
@@ -684,7 +726,12 @@ async function resolveCheckoutPromotionCode(
684
726
  coupon.amount_off && coupon.currency === "usd"
685
727
  ? coupon.amount_off
686
728
  : (coupon.currency_options?.usd?.amount_off ?? null);
687
- return { id: promotionCode.id, amountOffCents, percentOff: coupon.percent_off ?? null };
729
+ return {
730
+ id: promotionCode.id,
731
+ couponId: coupon.id,
732
+ amountOffCents,
733
+ percentOff: coupon.percent_off ?? null,
734
+ };
688
735
  }
689
736
 
690
737
  async function mirrorPaymentIntentCustomer(deps: ApiRouteDeps, event: Stripe.Event): Promise<void> {
@@ -792,18 +839,28 @@ async function releaseDisputedCredits(
792
839
  return;
793
840
  }
794
841
  const holdIdempotencyKey = `stripe:dispute_hold:${dispute.id}`;
795
- if (!(await hasCreditLedgerEntry(deps.db, accountId, holdIdempotencyKey))) {
796
- return;
797
- }
798
842
  const debitMicros = await creditAdjustmentMicros(deps, metadata, dispute.amount);
799
- await applyCreditLedgerEntry(deps.db, {
800
- accountId,
801
- type: "credit_dispute_release",
802
- amountMicros: debitMicros,
803
- sourceType: "stripe_dispute",
804
- sourceId: dispute.id,
805
- idempotencyKey: `stripe:dispute_release:${dispute.id}`,
806
- metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
843
+ await withRlsContext(deps.db, { accountId }, async (tx) => {
844
+ // Stripe can deliver the resolution before the hold. Materialize both
845
+ // receipts atomically so a late hold cannot withhold restored credits.
846
+ await applyCreditLedgerEntry(tx, {
847
+ accountId,
848
+ type: "credit_dispute_hold",
849
+ amountMicros: -debitMicros,
850
+ sourceType: "stripe_dispute",
851
+ sourceId: dispute.id,
852
+ idempotencyKey: holdIdempotencyKey,
853
+ metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
854
+ });
855
+ await applyCreditLedgerEntry(tx, {
856
+ accountId,
857
+ type: "credit_dispute_release",
858
+ amountMicros: debitMicros,
859
+ sourceType: "stripe_dispute",
860
+ sourceId: dispute.id,
861
+ idempotencyKey: `stripe:dispute_release:${dispute.id}`,
862
+ metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
863
+ });
807
864
  });
808
865
  }
809
866
 
@@ -1,3 +1,4 @@
1
+ import { signupCreditModelIds } from "@opengeni/config";
1
2
  import {
2
3
  CompleteSelfServiceOrganizationSetupRequest,
3
4
  CompleteSelfServiceOrganizationSetupResponse,
@@ -80,6 +81,7 @@ export function registerManagedOnboardingRoutes(
80
81
  organizationName,
81
82
  operationId: parsed.data.operationId,
82
83
  requestFingerprint,
84
+ trialCreditModelIds: signupCreditModelIds(deps.settings),
83
85
  trialCreditsEnabled:
84
86
  deps.settings.productAccessMode === "managed" &&
85
87
  deps.settings.verifiedSignupTrialCreditsEnabled,
@@ -1,3 +1,4 @@
1
+ import { signupCreditModelIds } from "@opengeni/config";
1
2
  import {
2
3
  lookupExternalIdentityForRequest,
3
4
  cancelExternalWorkspaceMemberGrantForRequest,
@@ -310,6 +311,7 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
310
311
  subjectId,
311
312
  subjectLabel: session.user.email || session.user.name,
312
313
  ...payload,
314
+ trialCreditModelIds: signupCreditModelIds(deps.settings),
313
315
  trialCreditsEnabled:
314
316
  deps.settings.productAccessMode === "managed" &&
315
317
  deps.settings.verifiedSignupTrialCreditsEnabled,
@@ -48,6 +48,8 @@ import {
48
48
  import { loadWorkspaceCodexModelAvailability } from "@opengeni/core";
49
49
  import {
50
50
  allWorkspacePermissions,
51
+ getBillingBalance,
52
+ spendableCreditMicros,
51
53
  createWorkspace,
52
54
  ensureWorkspaceByExternalIdentity,
53
55
  findWorkspaceByExternalIdentity,
@@ -621,25 +623,38 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
621
623
  // The same precedence the server applies when a new chat, API create, or
622
624
  // scheduled occurrence names no model; published so pickers show it.
623
625
  const workspaceSettings = workspace?.settings ?? {};
626
+ const creditBalance =
627
+ deps.settings.billingMode === "stripe"
628
+ ? await getBillingBalance(deps.db, grant.accountId)
629
+ : undefined;
624
630
  const defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
625
631
  settings: workspaceCatalogSettings,
626
632
  accountId: grant.accountId,
627
633
  workspaceSettings,
628
634
  selections,
629
635
  });
636
+ const catalog = projectWorkspaceModelCatalog(selections, {
637
+ defaultSelection,
638
+ creditsSelection: creditsDefaultSessionModel({
639
+ settings: workspaceCatalogSettings,
640
+ selections,
641
+ workspaceSettings,
642
+ }),
643
+ });
644
+ if (creditBalance) {
645
+ for (const model of catalog.models) {
646
+ if (model.cost !== "credits") continue;
647
+ model.creditFunding = creditBalance.promotionalCredits?.some(
648
+ (credit) => credit.remainingMicros > 0 && credit.eligibleModelIds.includes(model.id),
649
+ )
650
+ ? "promotional"
651
+ : spendableCreditMicros(creditBalance, model.id) > 0
652
+ ? "general"
653
+ : "unavailable";
654
+ }
655
+ }
630
656
  c.header("cache-control", "private, no-store");
631
- return c.json(
632
- WorkspaceModelCatalogResponse.parse(
633
- projectWorkspaceModelCatalog(selections, {
634
- defaultSelection,
635
- creditsSelection: creditsDefaultSessionModel({
636
- settings: workspaceCatalogSettings,
637
- selections,
638
- workspaceSettings,
639
- }),
640
- }),
641
- ),
642
- );
657
+ return c.json(WorkspaceModelCatalogResponse.parse(catalog));
643
658
  });
644
659
 
645
660
  app.get("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {