@whooshbang/contract-mock 2.3.1 → 2.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ recorded here.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 2.5.0
9
+
10
+ - Accept the published project configuration scopes while retaining organization and tenant boundaries (FXO-3028).
11
+ - Accept developer-friendly message and question shorthands, optional idempotency keys, inferred types and setup defaults from contracts 2.6.0.
12
+ - Create active webhook endpoints without the retired echo handshake, and expose credential-scoped flat endpoint routes.
13
+ - Preserve explicit request forms, replay semantics, consent evidence and optimistic group updates.
14
+
15
+ ## 2.4.0
16
+
17
+ - Use contracts 2.5.0 and serve the billing promotion routes, including the approved-price refusal for credit packs (FXO-2994).
18
+
8
19
  ## 2.3.1
9
20
 
10
21
  - Use contracts 2.4.0 and align scenario metadata with that contract (FXO-2828).
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # `@whooshbang/contract-mock`
2
2
 
3
3
  Deterministic, in-memory WhooshBang V1 mock for local contract tests.
4
- It implements the WhooshBang-owned release candidate without a cloud
4
+ It implements the published WhooshBang contract without a cloud
5
5
  organization, network service, database, or production provider.
6
6
 
7
- Run the current release candidate directly from the public npm registry:
7
+ Run the current release directly from the public npm registry:
8
8
 
9
9
  ```sh
10
10
  npx @whooshbang/contract-mock --port 8787
@@ -31,7 +31,7 @@ const response = await mock.fetch(
31
31
  );
32
32
  ```
33
33
 
34
- Install contracts, SDK and mock from the same published candidate batch.
34
+ Install compatible published contracts, SDK and mock versions.
35
35
  The tenant rename has no old-route or old-field aliases.
36
36
 
37
37
  The mock also executes the organization-scoped administration contract. Use
@@ -42,50 +42,33 @@ The mock also executes the organization-scoped administration contract. Use
42
42
 
43
43
  ## Billing consumer tests
44
44
 
45
- The mock executes the public wallet, recurring Checkout, billing portal,
46
- automatic top-up settings/card setup, project slot and attribution routes. Organization administrators may use a
47
- human session; OAuth grants also need explicit `billing:read` or
48
- `billing:write`. Project, organization API and machine credentials cannot
49
- perform financial operations. Attribution uses the organization-level
50
- `projects:read` and `projects:write` scopes, with administrator membership for
51
- changes. The MCP tools retain their own scope and action-confirmation checks.
52
-
53
- Each account starts with the once-only $2 starter credit and one project slot.
54
- The fixture project already occupies that slot. Multi-project consumer tests
55
- must purchase extra slots through recurring Checkout or release the old slot
56
- with `assignBillingProjectSlots` before creating its replacement. Old projects
57
- retain their data and recorded test access; new live sends require an active
58
- slot. The default recurring catalog contains the $5 attribution and project
59
- add-ons. Team/Agency prices and allowances are not invented: pass synthetic
60
- offers through `billing.offers` when a consumer test needs a base plan.
61
-
62
- Wallet purchases use the approved $5/$10/$25/$50/$100 packs and
63
- $5/$10/$30/$65/$150 total core credit. Purchased and bonus funds remain
64
- separate. One accepted live notification debits $0.003 for core delivery;
65
- shared email adds $0.001 from purchased or starter credit. Paid allowances are
66
- used first. Tests, provider retries, later failures and answers do not create
67
- extra charges. Payment replay cannot fund the same Checkout twice.
68
-
69
- These are synthetic payments. Creating Checkout returns a local fixture URL;
70
- reconciliation applies the fixture's payment outcome, not a query parameter
71
- or caller-supplied `paid` flag. No Stripe request, card charge or webhook proof
72
- occurs. Set `billing.checkoutOutcome` to a callback returning `paid`, `unpaid`
73
- or `expired` to exercise recovery. `billing.accounts` may seed exact decimal
74
- balances for boundary tests. State resets with `control.reset()`.
75
-
76
- Automatic top-up starts disabled and requires a confirmed pack, threshold and
77
- gross monthly cap covering that pack. The mock's card setup is synthetic:
78
- `billing.autoTopUpSetupOutcome` returns `ready`, `unpaid` or `expired`. It models
79
- owned setup recovery and rejects stale completion after settings change; it
80
- does not run a bank charge or a scheduled automatic-purchase engine. Saving a
81
- card does not grant delivery credit. Actual automatic payment capture, pending
82
- reservations and gross monthly cap enforcement are tested against the owned
83
- storage and Stripe SDK boundaries.
45
+ The mock implements saved-card setup, credit purchases, subscription changes,
46
+ automatic top-up settings, project slots and attribution. Use a synthetic human
47
+ session for organization administration; OAuth grants also need `billing:read`
48
+ or `billing:write`. Project and machine credentials cannot perform financial
49
+ operations.
50
+
51
+ Each organization starts with $2 of welcome credit and one project slot. The fixture
52
+ project already occupies that slot. Assign slots with `assignBillingProjectSlots`
53
+ when testing multiple projects. Test access remains available without a live slot.
54
+ The default recurring catalog contains the $5 attribution and project add-ons.
55
+ Pass synthetic `billing.offers` when a test needs Team or Agency allowances.
56
+
57
+ Wallet purchases use the $5/$10/$25/$50/$100 packs, granting
58
+ $5/$10/$30/$65/$150 total credit. Purchased and bonus funds stay separate. One
59
+ accepted live message costs $0.003 for core delivery; shared email adds $0.001
60
+ from purchased or welcome credit. Paid allowances are used first. Test sends,
61
+ provider retries, later delivery failures and answers add no charges.
62
+
63
+ These are synthetic payments. `billing.cardOutcome` returns `ready`, `open` or
64
+ `expired`; `billing.chargeOutcome` returns `paid`, `declined` or `requires_action`.
65
+ No Stripe request or real card charge occurs. Seed decimal balances with
66
+ `billing.accounts` for boundary tests. `control.reset()` clears state.
84
67
 
85
68
  ```ts
86
69
  const mock = createWhooshBangContractMock({
87
70
  billing: {
88
- checkoutOutcome: () => "unpaid",
71
+ chargeOutcome: () => "declined",
89
72
  accounts: [{
90
73
  organizationId: "organization_synthetic",
91
74
  starterCreditUsd: "0.003000",
@@ -94,15 +77,18 @@ const mock = createWhooshBangContractMock({
94
77
  });
95
78
  ```
96
79
 
97
- Paid add-on reconciliation enables the server-enforced attribution preference
98
- until its paid boundary. Restoring the preference restores visible attribution;
99
- expiry preserves the preference but removes its paid effect. Paid wallet
100
- purchases or current recurring periods select 90-day content retention;
101
- starter-only accounts select seven days. The mock projects that entitlement;
102
- it does not simulate the storage purge jobs or prove real recipient rendering.
80
+ Automatic top-up starts disabled. Enabling it requires a confirmed pack,
81
+ threshold, saved card and monthly cap. Saving a card alone grants no credit.
82
+ The mock does not run an automatic-purchase scheduler or prove bank settlement.
103
83
 
104
- Each project has one webhook endpoint across its environments. Paused and
105
- unverified endpoints still occupy it; disabling one releases it. Only a
84
+ Attribution preferences take effect while the paid entitlement is active.
85
+ Expiry preserves the preference but restores branding. A paid wallet purchase
86
+ or current recurring period selects 90-day content retention; welcome-credit-only
87
+ accounts select seven days. The mock projects the entitlement without simulating
88
+ storage purge jobs or proving recipient rendering.
89
+
90
+ Each project has one webhook endpoint across its environments. Active and
91
+ paused endpoints still occupy it; disabling one releases it. Only a
106
92
  terminal failed automatic cycle can receive the one included manual replay.
107
93
  `control.runPending()` executes that cycle using the same eight-attempt bound.
108
94
  The original cycle and manual recovery together cannot exceed 16 attempts.
@@ -118,7 +104,7 @@ values are local fixtures, not production credential material.
118
104
  It can also configure the project recipient experience, upload a bounded PNG
119
105
  logo, and manage sparse recipient-copy overrides. The mock normalizes the logo
120
106
  before storing it, atomically replaces the prior asset, projects its ID and the
121
- ten owner-voiced panel values into recipient sessions, and serves the PNG only
107
+ ten customer-editable panel values into recipient sessions, and serves the PNG only
122
108
  to that session's exact configured origin. System-owned safety, refusal, pause,
123
109
  unavailable, and unsubscribe copy is rejected just as it is by the service.
124
110
 
@@ -142,8 +128,9 @@ customer-managed provider resource that still needs manual retirement.
142
128
 
143
129
  Project credentials also execute the customer-endpoint lifecycle under
144
130
  `/v1/projects/{project_id}/environments/{environment_id}/customer-endpoints`:
145
- create with a one-time secret reveal, verify, bounded-overlap rotation, pause,
146
- resume, disable, and attempt inspection. Environment-scoped paths need an
131
+ create with a one-time secret reveal, signed diagnostic ping, bounded-overlap rotation, pause,
132
+ resume, disable, and attempt inspection. Endpoints are active immediately; no challenge echo is needed.
133
+ Flat `/v1/customer-endpoints` routes derive scope from a project credential. Environment-scoped paths need an
147
134
  environment identifier the bearer does not carry, so
148
135
  `CONTRACT_MOCK_FIXTURE_ENVIRONMENTS` publishes the synthetic project and
149
136
  environment identifiers the fixture credentials authenticate against. A path
@@ -151,7 +138,7 @@ naming another project or environment is refused as `resource_not_found`.
151
138
 
152
139
  The same credentials execute the Channel Connections surface under
153
140
  `/v1/projects/{project_id}/environments/{environment_id}/connection-providers`
154
- and `.../channel-connections`: read the catalogue of providers and the
141
+ and `.../channel-connections`: read the catalog of providers and the
155
142
  WhooshBang-owned identities an environment may select, then create a
156
143
  connection in `pending`, read it, list it, rename or disable it under the
157
144
  `row_version` that was read, and archive it. Archiving is terminal, so a
@@ -159,7 +146,7 @@ second archive or an edit to an archived connection is refused. Credential
159
146
  material and every reference to one are absent from these responses, including
160
147
  for the shared identity WhooshBang operates.
161
148
 
162
- The catalogue presents hosted Slack first as
149
+ The catalog presents hosted Slack first as
163
150
  `WhooshBang for Slack — Recommended`. Creating that arm returns a durable
164
151
  `authorization_setup`; fetching its mock authorization URL completes the
165
152
  provider browser round trip and returns to the Dashboard with the same
@@ -169,7 +156,7 @@ verification/delivery timestamps. `.../{connection_id}/reauthorize` repeats
169
156
  the flow on the exact connection, health verifies it, archive is repeat-safe,
170
157
  and purge is refused because the tenant does not own the platform installation.
171
158
 
172
- When the owner enables Slack in N17 and exactly one eligible Slack connection
159
+ When Slack is enabled and exactly one eligible Slack connection
173
160
  is attached to the notifier, a recipient session offers Slack. Its provider
174
161
  handoff binds that exact connection; ambiguity or a lifecycle change fails
175
162
  closed. Environment-scoped agent sends may include `connection_id`, and the
package/dist/billing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AutoTopUpSettings, UpdateAutoTopUpSettingsRequest, BillingAccount, BillingEntitlements, BillingInvoiceCollection, BillingProjectSlots, CardSetup, ContractValidator, CreditPurchase, Problem, ProblemCode, Project, ProjectAttribution, SubscriptionChange, SubscriptionSelection } from "@whooshbang/contracts";
1
+ import type { AutoTopUpSettings, UpdateAutoTopUpSettingsRequest, BillingAccount, BillingEntitlements, BillingInvoiceCollection, BillingProjectSlots, BillingPromotion, BillingPromotions, CardSetup, ContractValidator, CreditPurchase, Problem, ProblemCode, Project, ProjectAttribution, SubscriptionChange, SubscriptionSelection } from "@whooshbang/contracts";
2
2
  import type { Hono } from "hono";
3
3
  import type { ContractMockBillingOptions, OrganizationScope } from "./types.js";
4
4
  interface ChangeState {
@@ -36,6 +36,9 @@ export declare class MockBillingState {
36
36
  diagnosticId: string;
37
37
  }>;
38
38
  readonly offers: BillingEntitlements["offers"];
39
+ private readonly promotionCodes;
40
+ private readonly promotionGrants;
41
+ private readonly promotionRefusals;
39
42
  private sequence;
40
43
  constructor(options?: ContractMockBillingOptions);
41
44
  private id;
@@ -43,6 +46,9 @@ export declare class MockBillingState {
43
46
  private record;
44
47
  private outcome;
45
48
  readAccount(organizationId: string, now?: string): BillingAccount;
49
+ readPromotions(organizationId: string, now: string): BillingPromotions;
50
+ /** `refused` covers every unredeemable code identically, by design. */
51
+ redeemPromotion(organizationId: string, code: string, now: string): BillingPromotion | "refused" | "throttled";
46
52
  setReceiptEmail(organizationId: string, email: string | null): void;
47
53
  createCardSetup(organizationId: string): CardSetup;
48
54
  reconcileCardSetup(organizationId: string, id: string): CardSetup | null;
package/dist/billing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { validateAutoTopUpSettings, validateUpdateAutoTopUpSettingsRequest, validateAssignBillingProjectSlotsRequest, validateBillingAccount, validateBillingEntitlements, validateBillingInvoiceCollection, validateCardSetup, validateConfirmSubscriptionChangeRequest, validateCreateCreditPurchaseRequest, validateCreditPurchase, validateBillingProjectSlots, validateProjectAttribution, validateSubscriptionChange, validateSubscriptionSelection, validateUpdateBillingReceiptEmailRequest, validateUpdateProjectAttributionRequest, } from "@whooshbang/contracts";
1
+ import { validateAutoTopUpSettings, validateUpdateAutoTopUpSettingsRequest, validateAssignBillingProjectSlotsRequest, validateBillingAccount, validateBillingEntitlements, validateBillingInvoiceCollection, validateCardSetup, validateConfirmSubscriptionChangeRequest, validateCreateCreditPurchaseRequest, validateCreditPurchase, validateBillingProjectSlots, validateBillingPromotion, validateBillingPromotions, validateProjectAttribution, validateRedeemBillingPromotionRequest, validateSubscriptionChange, validateSubscriptionSelection, validateUpdateBillingReceiptEmailRequest, validateUpdateProjectAttributionRequest, } from "@whooshbang/contracts";
2
2
  const packs = [
3
3
  { purchase_cents: 500, core_credit_usd: "5.000000" },
4
4
  { purchase_cents: 1000, core_credit_usd: "10.000000" },
@@ -24,6 +24,24 @@ const defaultOffers = [
24
24
  hide_attribution: false,
25
25
  },
26
26
  ];
27
+ /** The documented synthetic code every mock offers unless `promotionCodes` is set. */
28
+ const defaultPromotionCodes = [
29
+ {
30
+ code: "MOCK-CREDIT",
31
+ name: "Mock launch credit",
32
+ benefit: { kind: "message_credit", credit_usd: "10.000000" },
33
+ benefit_days: 30,
34
+ },
35
+ ];
36
+ /** Refusals one organization may collect before the mock answers 429. */
37
+ const promotionRefusalLimit = 5;
38
+ /** Case, spaces, hyphens and underscores never distinguish two codes. */
39
+ function normalizePromotionCode(code) {
40
+ return code
41
+ .trim()
42
+ .toUpperCase()
43
+ .replace(/[\s_-]/gu, "");
44
+ }
27
45
  const mockCard = {
28
46
  brand: "visa",
29
47
  last_four: "4242",
@@ -69,6 +87,9 @@ export class MockBillingState {
69
87
  attribution = new Map();
70
88
  replays = new Map();
71
89
  offers;
90
+ promotionCodes = new Map();
91
+ promotionGrants = new Map();
92
+ promotionRefusals = new Map();
72
93
  sequence = 0;
73
94
  constructor(options = {}) {
74
95
  this.options = options;
@@ -95,6 +116,21 @@ export class MockBillingState {
95
116
  offer.hide_attribution !== (offer.kind === "attribution"))))
96
117
  throw new TypeError("Mock billing offers must preserve the approved catalog shape.");
97
118
  }
119
+ for (const seed of options.promotionCodes ?? defaultPromotionCodes) {
120
+ const normalized = normalizePromotionCode(seed.code);
121
+ if (normalized.length < 4 ||
122
+ this.promotionCodes.has(normalized) ||
123
+ !Number.isSafeInteger(seed.benefit_days) ||
124
+ seed.benefit_days < 1 ||
125
+ seed.name.length < 1 ||
126
+ (seed.benefit.kind === "message_credit" &&
127
+ microUsd(seed.benefit.credit_usd) <= 0n) ||
128
+ (seed.benefit.kind === "shared_emails" &&
129
+ (!Number.isSafeInteger(seed.benefit.shared_emails) ||
130
+ seed.benefit.shared_emails < 1)))
131
+ throw new TypeError("Mock promotion codes must be unique, named, and grant a positive benefit.");
132
+ this.promotionCodes.set(normalized, structuredClone(seed));
133
+ }
98
134
  for (const seed of options.accounts ?? []) {
99
135
  if (this.accounts.has(seed.organizationId))
100
136
  throw new TypeError("Duplicate mock billing account.");
@@ -160,6 +196,77 @@ export class MockBillingState {
160
196
  receipt_email: account.receiptEmail,
161
197
  };
162
198
  }
199
+ readPromotions(organizationId, now) {
200
+ const at = Date.parse(now);
201
+ let credit = 0n;
202
+ let emails = 0;
203
+ let branding = null;
204
+ const promotions = (this.promotionGrants.get(organizationId) ?? []).map(({ resource }) => {
205
+ const live = resource.status === "active" &&
206
+ resource.ends_at !== null &&
207
+ Date.parse(resource.ends_at) > at;
208
+ const current = structuredClone(resource);
209
+ if (resource.status === "active" && !live)
210
+ current.status = "expired";
211
+ if (live && resource.ends_at) {
212
+ const benefit = resource.benefit;
213
+ if (benefit.kind === "message_credit")
214
+ credit += microUsd(benefit.remaining_usd);
215
+ else if (benefit.kind === "shared_emails")
216
+ emails += benefit.remaining;
217
+ else if (benefit.kind === "branding" &&
218
+ (branding === null || resource.ends_at > branding))
219
+ branding = resource.ends_at;
220
+ }
221
+ return current;
222
+ });
223
+ return {
224
+ promotional_credit_usd: usd(credit),
225
+ free_shared_emails: emails,
226
+ branding_removed_until: branding,
227
+ promotions,
228
+ };
229
+ }
230
+ /** `refused` covers every unredeemable code identically, by design. */
231
+ redeemPromotion(organizationId, code, now) {
232
+ const refusals = this.promotionRefusals.get(organizationId) ?? 0;
233
+ if (refusals >= promotionRefusalLimit)
234
+ return "throttled";
235
+ const normalized = normalizePromotionCode(code);
236
+ const seed = this.promotionCodes.get(normalized);
237
+ const grants = this.promotionGrants.get(organizationId) ?? [];
238
+ if (!seed || grants.some((grant) => grant.code === normalized)) {
239
+ this.promotionRefusals.set(organizationId, refusals + 1);
240
+ return "refused";
241
+ }
242
+ this.promotionRefusals.delete(organizationId);
243
+ const benefit = seed.benefit.kind === "message_credit"
244
+ ? {
245
+ kind: "message_credit",
246
+ credit_usd: seed.benefit.credit_usd,
247
+ remaining_usd: seed.benefit.credit_usd,
248
+ }
249
+ : seed.benefit.kind === "shared_emails"
250
+ ? {
251
+ kind: "shared_emails",
252
+ shared_emails: seed.benefit.shared_emails,
253
+ remaining: seed.benefit.shared_emails,
254
+ }
255
+ : { kind: "branding" };
256
+ const resource = {
257
+ id: `grn_${(++this.sequence).toString(36).padStart(26, "0")}`,
258
+ source: "code",
259
+ name: seed.name,
260
+ status: "active",
261
+ starts_at: now,
262
+ ends_at: new Date(Date.parse(now) + seed.benefit_days * 86_400_000).toISOString(),
263
+ ended_at: null,
264
+ benefit,
265
+ };
266
+ grants.unshift({ code: normalized, resource });
267
+ this.promotionGrants.set(organizationId, grants);
268
+ return structuredClone(resource);
269
+ }
163
270
  setReceiptEmail(organizationId, email) {
164
271
  this.account(organizationId).receiptEmail = email;
165
272
  }
@@ -215,6 +322,9 @@ export class MockBillingState {
215
322
  id,
216
323
  purchase_cents: pack.purchase_cents,
217
324
  credit_usd: pack.core_credit_usd,
325
+ // The mock models no pack discounts; the card pays the pack price.
326
+ charged_usd: `${String(pack.purchase_cents / 100)}.000000`,
327
+ discount_usd: "0.000000",
218
328
  status: "processing",
219
329
  failure: null,
220
330
  client_secret: null,
@@ -363,6 +473,8 @@ export class MockBillingState {
363
473
  monthly_price_usd: usd(targetTotal),
364
474
  charge_now_usd: usd(targetTotal),
365
475
  unused_time_credit_usd: null,
476
+ promotion: null,
477
+ renewal_promotion: null,
366
478
  effective_at: null,
367
479
  expires_at: expires,
368
480
  client_secret: null,
@@ -430,6 +542,8 @@ export class MockBillingState {
430
542
  monthly_price_usd: usd(targetTotal),
431
543
  charge_now_usd: upgrade ? usd(charge) : "0.000000",
432
544
  unused_time_credit_usd: upgrade ? "0.000000" : null,
545
+ promotion: null,
546
+ renewal_promotion: null,
433
547
  effective_at: upgrade ? null : paid.end,
434
548
  expires_at: expires,
435
549
  client_secret: null,
@@ -662,7 +776,7 @@ export function addMockBillingRoutes(app, hooks) {
662
776
  context.res.headers.set("cache-control", "no-store");
663
777
  });
664
778
  const responseBodies = new WeakMap();
665
- const mutate = (request, organizationId, operation, body, status, action) => {
779
+ const mutate = (request, organizationId, operation, body, status, action, replayStatus) => {
666
780
  const key = request.headers.get("idempotency-key");
667
781
  if (!key || key.length < 8 || key.length > 255 || !/^[!-~]+$/u.test(key))
668
782
  return hooks.fail("idempotency_key_required", 400);
@@ -684,7 +798,7 @@ export function addMockBillingRoutes(app, hooks) {
684
798
  state.changes.get(id)?.resource)
685
799
  : undefined;
686
800
  return new Response(JSON.stringify(fresh ?? prior.body), {
687
- status: prior.status,
801
+ status: replayStatus ?? prior.status,
688
802
  headers: {
689
803
  "content-type": "application/json",
690
804
  "cache-control": "no-store",
@@ -793,6 +907,12 @@ export function addMockBillingRoutes(app, hooks) {
793
907
  if (body instanceof Response)
794
908
  return body;
795
909
  return mutate(context.req.raw, org, "createCreditPurchase", body, 201, () => {
910
+ // The mock has no pack promotions: the approved charge must be the
911
+ // pack price, as the real API refuses any other.
912
+ if (body.approved_charge_usd !== undefined &&
913
+ body.approved_charge_usd !==
914
+ `${String(body.purchase_cents / 100)}.000000`)
915
+ return hooks.fail("request_invalid", 400, "credit_purchase_price_changed", "This pack's price changed before it was charged, so nothing was charged. Review the price and try again.");
796
916
  const purchase = hooks
797
917
  .state()
798
918
  .createCreditPurchase(org, body.purchase_cents, hooks.now());
@@ -897,6 +1017,35 @@ export function addMockBillingRoutes(app, hooks) {
897
1017
  return replayable(hooks.state().readSlots(org, hooks.projects(), hooks.now()), validateBillingProjectSlots, 200);
898
1018
  });
899
1019
  });
1020
+ app.get("/v1/billing/promotions", (context) => {
1021
+ const org = hooks.authorize(context.req.raw, "billing:read", true);
1022
+ return org instanceof Response
1023
+ ? org
1024
+ : hooks.reply(hooks.state().readPromotions(org, hooks.now()), validateBillingPromotions);
1025
+ });
1026
+ app.post("/v1/billing/promotions/redeem", async (context) => {
1027
+ const org = hooks.authorize(context.req.raw, "billing:write", true);
1028
+ if (org instanceof Response)
1029
+ return org;
1030
+ const body = await hooks.read(context.req.raw, validateRedeemBillingPromotionRequest);
1031
+ if (body instanceof Response)
1032
+ return body;
1033
+ return mutate(context.req.raw, org, "redeemBillingPromotion", { code: normalizePromotionCode(body.code) }, 201, () => {
1034
+ const result = hooks
1035
+ .state()
1036
+ .redeemPromotion(org, body.code, hooks.now());
1037
+ if (result === "throttled") {
1038
+ const refused = hooks.fail("rate_limited", 429);
1039
+ const headers = new Headers(refused.headers);
1040
+ headers.set("retry-after", "60");
1041
+ return new Response(refused.body, { status: 429, headers });
1042
+ }
1043
+ // One answer for a wrong, spent, expired or already-redeemed code.
1044
+ if (result === "refused")
1045
+ return hooks.fail("resource_not_found", 404);
1046
+ return replayable(result, validateBillingPromotion, 201);
1047
+ }, 200);
1048
+ });
900
1049
  for (const method of ["get", "put"]) {
901
1050
  app[method]("/v1/projects/:projectId/attribution", async (context) => {
902
1051
  context.header("cache-control", "no-store");