@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 +11 -0
- package/README.md +44 -57
- package/dist/billing.d.ts +7 -1
- package/dist/billing.js +152 -3
- package/dist/billing.js.map +1 -1
- package/dist/core.js +115 -32
- package/dist/core.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +20 -0
- package/package.json +3 -3
- package/scenarios/v1/manifest.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
46
|
-
automatic top-up settings
|
|
47
|
-
|
|
48
|
-
`billing:write`. Project
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
The
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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,
|
|
146
|
-
resume, disable, and attempt inspection.
|
|
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
|
|
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
|
|
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
|
|
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");
|