@portalshq/capability-interaction-orchestrator 0.0.0-stage → 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 +247 -2
- package/dist/arbitration-engine.d.ts +20 -0
- package/dist/arbitration-engine.d.ts.map +1 -0
- package/dist/arbitration-engine.js +67 -0
- package/dist/billing-controller.d.ts +38 -0
- package/dist/billing-controller.d.ts.map +1 -0
- package/dist/billing-controller.js +239 -0
- package/dist/db/repository.d.ts +41 -0
- package/dist/db/repository.d.ts.map +1 -0
- package/dist/db/repository.js +385 -0
- package/dist/event-publisher.d.ts +30 -0
- package/dist/event-publisher.d.ts.map +1 -0
- package/dist/event-publisher.js +122 -0
- package/dist/execution-bridge.d.ts +27 -0
- package/dist/execution-bridge.d.ts.map +1 -0
- package/dist/execution-bridge.js +48 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/moderation-gateway.d.ts +36 -0
- package/dist/moderation-gateway.d.ts.map +1 -0
- package/dist/moderation-gateway.js +167 -0
- package/dist/orchestrator.d.ts +36 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +258 -0
- package/dist/policies/index.d.ts +33 -0
- package/dist/policies/index.d.ts.map +1 -0
- package/dist/policies/index.js +177 -0
- package/dist/submission-gateway.d.ts +26 -0
- package/dist/submission-gateway.d.ts.map +1 -0
- package/dist/submission-gateway.js +102 -0
- package/dist/types.d.ts +322 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/validation.d.ts +20 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +221 -0
- package/dist/window-manager.d.ts +27 -0
- package/dist/window-manager.d.ts.map +1 -0
- package/dist/window-manager.js +57 -0
- package/package.json +54 -4
- package/sql/001_interaction_orchestrator_core.sql +143 -0
- package/sql/002_durable_lifecycle.sql +44 -0
package/README.md
CHANGED
|
@@ -1,3 +1,248 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @portalshq/capability-interaction-orchestrator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A durable interaction lifecycle for input windows, moderated submissions, selection,
|
|
4
|
+
credit-backed billing, execution handoff and replayable events. PostgreSQL owns
|
|
5
|
+
admission, quotas, idempotency, credit allocations, resolutions and event ordering.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Requires Node.js 22+, PostgreSQL, and the `@portalshq/monetization` entitlement schema
|
|
10
|
+
in the **same database**. Apply migrations with errors stopping the transaction:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction \
|
|
14
|
+
-f node_modules/@portalshq/monetization/sql/001_monetization.sql \
|
|
15
|
+
-f node_modules/@portalshq/capability-interaction-orchestrator/sql/001_interaction_orchestrator_core.sql \
|
|
16
|
+
-f node_modules/@portalshq/capability-interaction-orchestrator/sql/002_durable_lifecycle.sql
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Deploy the accompanying monetization update together with this package: both use
|
|
20
|
+
ID order for grant locks while consuming the oldest matching credits first.
|
|
21
|
+
The old `Stub*` exports have been removed. The executable classes now require their
|
|
22
|
+
actual dependencies; no implementation silently grants a payment or host approval.
|
|
23
|
+
|
|
24
|
+
## Lifecycle interface
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { Pool } from 'pg';
|
|
28
|
+
import {
|
|
29
|
+
InteractionRepository,
|
|
30
|
+
InteractionOrchestrator,
|
|
31
|
+
} from '@portalshq/capability-interaction-orchestrator';
|
|
32
|
+
|
|
33
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
34
|
+
const repository = new InteractionRepository(pool);
|
|
35
|
+
const interactions = new InteractionOrchestrator(repository);
|
|
36
|
+
|
|
37
|
+
const window = await interactions.windows.createWindow({
|
|
38
|
+
worldId: 'channel-123',
|
|
39
|
+
branchId: 'canonical',
|
|
40
|
+
baseCommitHash: 'current-commit',
|
|
41
|
+
conflictKey: 'story',
|
|
42
|
+
allowedInputKinds: ['custom_prompt'],
|
|
43
|
+
arbitrationPolicy: 'fifo',
|
|
44
|
+
billingPolicy: 'prepaid_credits',
|
|
45
|
+
billingTerms: {
|
|
46
|
+
tenantId: 'creator-tenant',
|
|
47
|
+
entitlementKind: 'creator-tenant:story_guide',
|
|
48
|
+
amountCredits: 1,
|
|
49
|
+
amountCents: 100,
|
|
50
|
+
currency: 'USD',
|
|
51
|
+
// Set sessionId only when the actual entitlement scope names a session.
|
|
52
|
+
},
|
|
53
|
+
maxSubmissionsPerUser: 1,
|
|
54
|
+
maxSelectedInputs: 5,
|
|
55
|
+
opensAt: new Date().toISOString(),
|
|
56
|
+
closesAt: null,
|
|
57
|
+
contentRating: 'E',
|
|
58
|
+
});
|
|
59
|
+
await interactions.windows.openWindow(window.id);
|
|
60
|
+
|
|
61
|
+
const submission = await interactions.submissions.submitIntent({
|
|
62
|
+
windowId: window.id,
|
|
63
|
+
actorId: 'authenticated-consumer-id',
|
|
64
|
+
idempotencyKey: 'request-id-kept-across-retries',
|
|
65
|
+
kind: 'custom_prompt',
|
|
66
|
+
payload: { prompt: 'Explore the old lighthouse' },
|
|
67
|
+
});
|
|
68
|
+
if (submission.status !== 'eligible') {
|
|
69
|
+
// Rejected inputs and inputs awaiting human review do not enter selection.
|
|
70
|
+
console.log(submission.status);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`actorId` must come from authenticated application identity. Authorize the caller
|
|
75
|
+
against the window's world before exposing submission, cancellation, review,
|
|
76
|
+
host-selection, refund or event methods over a network. This package is an internal
|
|
77
|
+
module; it does not authenticate HTTP requests. Window configuration, billing terms,
|
|
78
|
+
policy configuration and moderation context come from trusted application code.
|
|
79
|
+
Submission payloads and app metadata cannot set prices, rank or host authority.
|
|
80
|
+
|
|
81
|
+
The server reloads prices for `billing.quote(window, actorId)`; the quote is an
|
|
82
|
+
informational display, not a payment approval. All paid policies settle **credits
|
|
83
|
+
already granted by monetization**. Fiat checkout, Stripe authorization and monetary
|
|
84
|
+
refunds belong to monetization's purchase lifecycle. No synthetic Stripe holds are
|
|
85
|
+
created here.
|
|
86
|
+
|
|
87
|
+
Input JSON is limited to 64 KiB and 32 nesting levels; timestamps use ISO UTC with
|
|
88
|
+
`Z`. Prompts/messages require their text field; votes require an option or target ID
|
|
89
|
+
(and both must agree when present); mutations require target IDs and a parameter
|
|
90
|
+
object; object creation requires `payload.object`; branch creation requires a name.
|
|
91
|
+
Configure `allowedInputKinds` to operations implemented by your execution adapter.
|
|
92
|
+
|
|
93
|
+
### Resolve, execute, commit
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// For finite windows, closeWindow first. An always-open queue can resolve directly.
|
|
97
|
+
const resolution = await interactions.resolveWindow(window.id, {
|
|
98
|
+
idempotencyKey: 'generation-request-id',
|
|
99
|
+
});
|
|
100
|
+
const executable = await interactions.prepareExecution(resolution.id);
|
|
101
|
+
// Pass executable to your execution adapter to generate a private preview/artifact.
|
|
102
|
+
// Keep proposed world changes private until output moderation and commit succeed.
|
|
103
|
+
// Then call commitResolution(resolution.id, proposedOutput, resultingCommitHash).
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`resolveWindow` persists selection and losing-input refunds atomically. Selection
|
|
107
|
+
has a window-scoped retry key; reusing it with a different host selection fails.
|
|
108
|
+
Only one selected/executing resolution may exist per window. `prepareExecution`
|
|
109
|
+
checks backing grants and captures selection-dependent credits before execution.
|
|
110
|
+
`commitResolution(id, output, hash)` moderates the complete output, captures deferred
|
|
111
|
+
publication credits and records fulfillment and events together. A non-allow output
|
|
112
|
+
fails the resolution and releases/refunds its credits. For parallel branches, supply
|
|
113
|
+
`{ [selectedSubmissionId]: branchCommitHash }` instead of one hash; the canonical
|
|
114
|
+
head stays unchanged. Output and commit targets must match on a retry.
|
|
115
|
+
|
|
116
|
+
The owned execution adapter must enforce base-commit conflicts against its world
|
|
117
|
+
store. A hash supplied to this module identifies a private proposed artifact;
|
|
118
|
+
publication happens only after a committed resolution. PostgreSQL transactions do
|
|
119
|
+
not make an external provider call or world-store write atomic.
|
|
120
|
+
|
|
121
|
+
`failResolution(id, reason)` refunds selected work and records failure. Use it after
|
|
122
|
+
an execution failure or expired/revoked hold; retrying a failed resolution requires
|
|
123
|
+
a new submission. `cancelWindow(id)` cancels pending inputs and refunds their credits
|
|
124
|
+
in one transaction, refusing to interrupt an active resolution.
|
|
125
|
+
|
|
126
|
+
## Selection policies
|
|
127
|
+
|
|
128
|
+
| Policy | Behavior |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `fifo` | Admission order, with ID as the final deterministic tie-break |
|
|
131
|
+
| `first_accepted` | The first eligible input |
|
|
132
|
+
| `plurality` | Distinct actors per option/target, descending vote count, then admission order; selects one representative per winning target |
|
|
133
|
+
| `ranked` | Trusted `arbitrationPolicyConfig.priorities[submissionId]`, then admission order; ignores payload priority |
|
|
134
|
+
| `batch` | Admission order, combining nonoverlapping `targetEntityIds` up to capacity |
|
|
135
|
+
| `parallel_branches` | Admission order up to capacity, one stable branch destination per selected input |
|
|
136
|
+
| `host_selection` | Explicit `selectedSubmissionIds` passed to `resolveWindow`; rejects unknown, duplicate or excessive IDs |
|
|
137
|
+
|
|
138
|
+
All policies respect `maxSelectedInputs`. Custom policies must partition the eligible
|
|
139
|
+
inputs exactly once into selected and rejected IDs. The arbitration engine is a
|
|
140
|
+
pure selection module; its `resolutionSequence: 0` is an unpersisted proposal.
|
|
141
|
+
The orchestrator allocates the actual monotonic sequence under the window lock.
|
|
142
|
+
|
|
143
|
+
For always-open FIFO/parallel queues, overflow stays eligible for the next generation
|
|
144
|
+
and is recorded in `appMetadata.deferredSubmissionIds`. Finite-window losers are
|
|
145
|
+
marked `not_selected` and refunded. Prompt/message/vote inputs can follow a moving
|
|
146
|
+
queue head; state-changing inputs with an old base must be cancelled and resubmitted
|
|
147
|
+
after application revalidation. Active per-user quota counts pending, eligible,
|
|
148
|
+
selected and executing inputs; completed/rejected/cancelled inputs free that quota.
|
|
149
|
+
Scheduled windows can coexist, but opening two windows with the same world, branch
|
|
150
|
+
and conflict key fails.
|
|
151
|
+
|
|
152
|
+
## Moderation
|
|
153
|
+
|
|
154
|
+
`ModerationGateway` checks every nested string in JSON input/output. Its defaults
|
|
155
|
+
provide configurable term rules, Unicode normalization, word edges, input length
|
|
156
|
+
200 and output length 32,000. E blocks mild/strong profanity, explicit terms and
|
|
157
|
+
graphic violence terms; T permits mild profanity; T+ permits strong profanity and
|
|
158
|
+
graphic violence; M permits these categories. Blocked/prohibited terms apply at
|
|
159
|
+
every rating. These are text rules, not semantic or image/video classification.
|
|
160
|
+
Install application/model validators for contextual content decisions and non-text
|
|
161
|
+
media inspection. Version your configuration with `moderationPolicyVersion`.
|
|
162
|
+
|
|
163
|
+
Custom validators cannot bypass the built-in rules. Missing `validateOutput` falls
|
|
164
|
+
back to `validate` with the complete output in a synthetic submission. Validator
|
|
165
|
+
exceptions and malformed decisions stop qualification. `review` and `transform`
|
|
166
|
+
remain in `moderating`; only an explicit trusted
|
|
167
|
+
`submissions.reviewSubmission(id, 'allow' | 'reject', reasons)` completes them.
|
|
168
|
+
Transforms are never silently applied. `contextFor(window)` injects application
|
|
169
|
+
world rules; each request uses its own rating context.
|
|
170
|
+
|
|
171
|
+
## Billing behavior
|
|
172
|
+
|
|
173
|
+
| Policy | Admission | Selection/execution | Commit |
|
|
174
|
+
| --- | --- | --- | --- |
|
|
175
|
+
| `free` | Zero-credit reservation | Verify lifecycle | No charge |
|
|
176
|
+
| `prepaid_credits` | Consume credits | Validate backing grants | Fulfill |
|
|
177
|
+
| `pay_to_generate` | Consume credits | Validate backing grants | Fulfill |
|
|
178
|
+
| `pay_for_guaranteed_slot` | Claim capacity and consume credits | Guaranteed FIFO/parallel selection | Fulfill |
|
|
179
|
+
| `capture_if_selected` | Deduct a credit hold from spendable balance | Capture selected holds; release losers | Fulfill |
|
|
180
|
+
| `pay_to_publish` | Deduct a credit hold | Generate a private preview | Capture only on allowed output |
|
|
181
|
+
|
|
182
|
+
Guaranteed slots require FIFO or parallel selection and cannot overbook capacity.
|
|
183
|
+
Reservations default to one hour; configure `reservationTtlMs` (1 second–24 hours).
|
|
184
|
+
A hold expires no later than its earliest backing grant. Captured prepaid credits
|
|
185
|
+
remain spent while queued. Cancellation, failed execution, non-allow output and
|
|
186
|
+
finite-window loss release/refund exactly once, restoring only surviving unexpired
|
|
187
|
+
original grants. A deleted/refunded purchase's grant is never recreated. Expired
|
|
188
|
+
credits cannot be made spendable by an interaction refund. Deferred captures check
|
|
189
|
+
expiry and revocation again at execution/publication.
|
|
190
|
+
|
|
191
|
+
## Events and workers
|
|
192
|
+
|
|
193
|
+
`EventPublisher` accepts `createPostgresFanoutBus(pool)` or `InMemoryFanoutBus` through
|
|
194
|
+
the fanout seam. Set up `fanout_messages` as documented by
|
|
195
|
+
[`capability-realtime-fanout-postgres`](../capability-realtime-fanout-postgres).
|
|
196
|
+
Lifecycle writes enqueue events; call `drainOutbox()` from a worker. Publishing to
|
|
197
|
+
the transport is at least once, with expiring claims and token-checked acknowledgment.
|
|
198
|
+
Failed deliveries stay durable for retry. Each event has a stable ID and a
|
|
199
|
+
transactionally allocated world sequence. Topics use `interaction:${worldId}`.
|
|
200
|
+
|
|
201
|
+
`subscribe(worldId, afterSequence?, { signal? })` returns an async iterator. With a
|
|
202
|
+
cursor it replays all later events in bounded pages; without one it starts at the
|
|
203
|
+
current head. Fanout messages wake the iterator; the database remains authoritative
|
|
204
|
+
for ordering and deduplication. Polling recovers missed hints. Abort, iterator return
|
|
205
|
+
and exceptions release subscriptions and timers, including a read that is waiting.
|
|
206
|
+
Use `String(event.sequence)` for SSE `id`; validate and authorize `Last-Event-ID`.
|
|
207
|
+
`getEvents` returns at most 100 events per page. The iterator handles pagination.
|
|
208
|
+
|
|
209
|
+
Run these bounded jobs with error reporting and nonoverlapping invocations:
|
|
210
|
+
|
|
211
|
+
- `windows.startWindowCloser(5000)` closes due finite windows; always-open queues remain open.
|
|
212
|
+
- `billing.expireReservations()` releases expired, unselected holds in batches of 100.
|
|
213
|
+
- `submissions.resumeSubmission(id)` recovers a durable `received` admission after an outage. Find these through application recovery jobs or `listSubmissions(windowId, 'received')`.
|
|
214
|
+
- `events.drainOutbox()` delivers a bounded outbox batch; repeat while work remains.
|
|
215
|
+
|
|
216
|
+
Open scheduled windows explicitly at their scheduled time. Finish the active
|
|
217
|
+
always-open queue resolution before explicitly closing its intake. Stop/await the closer,
|
|
218
|
+
abort/return all event iterators, finish in-flight worker jobs and then close the
|
|
219
|
+
caller-owned fanout bus and pool during shutdown. A selected/executing expired hold
|
|
220
|
+
requires `failResolution`; it is never silently reassigned. Use trusted operator
|
|
221
|
+
`billing.refund` for completed work; active resolutions must fail first.
|
|
222
|
+
|
|
223
|
+
Repository transactions serialize admission/selection per window, world event writes
|
|
224
|
+
per world, and credit mutations in grant-ID order. Keep network calls outside these
|
|
225
|
+
transactions. The exported repository and `*InTransaction` methods are low-level
|
|
226
|
+
integration seams; use `InteractionOrchestrator` for lifecycle operations. Retain
|
|
227
|
+
outbox history for the promised replay period and monitor unpublished age, pending
|
|
228
|
+
qualification age, expiring holds and failed resolutions. This package does not
|
|
229
|
+
silently prune audit/replay data.
|
|
230
|
+
|
|
231
|
+
## Validation and migration
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
npm run build --workspace=packages/monetization
|
|
235
|
+
npm run build --workspace=packages/capability-interaction-orchestrator
|
|
236
|
+
npm run typecheck --workspace=packages/capability-interaction-orchestrator
|
|
237
|
+
npm run test:coverage --workspace=packages/capability-interaction-orchestrator
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Tests use embedded PostgreSQL (PGlite) by default, applying real migrations. To verify
|
|
241
|
+
multiple connections against a scratch PostgreSQL instance, set both
|
|
242
|
+
`INTERACTION_TEST_MODE=non-production` and `INTERACTION_TEST_DATABASE_URL`. Each suite
|
|
243
|
+
creates/drops only its randomly named schema. Coverage thresholds are 95% for lines,
|
|
244
|
+
statements, functions and branches and are enforced in the PostgreSQL CI job.
|
|
245
|
+
|
|
246
|
+
See the repository's
|
|
247
|
+
[migration guide](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/bears-live-app-migration.md)
|
|
248
|
+
and [advanced usage](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/advanced-usage.md).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ArbitrationPolicyName, InputWindow, Resolution, Submission } from "./types.js";
|
|
2
|
+
export interface ArbitrationPolicy {
|
|
3
|
+
readonly name: ArbitrationPolicyName;
|
|
4
|
+
resolve(window: InputWindow, submissions: Submission[]): Resolution;
|
|
5
|
+
}
|
|
6
|
+
export interface IArbitrationEngine {
|
|
7
|
+
resolve(window: InputWindow, submissions: Submission[]): Promise<Resolution>;
|
|
8
|
+
registerPolicy(name: string, policy: ArbitrationPolicy): void;
|
|
9
|
+
setPolicyConfig(policyName: string, config: Record<string, unknown>): void;
|
|
10
|
+
}
|
|
11
|
+
/** Pure selection. Persistence and monotonic resolution sequences belong to the orchestrator. */
|
|
12
|
+
export declare class ArbitrationEngine implements IArbitrationEngine {
|
|
13
|
+
private readonly policies;
|
|
14
|
+
private readonly configs;
|
|
15
|
+
constructor();
|
|
16
|
+
registerPolicy(name: string, policy: ArbitrationPolicy): void;
|
|
17
|
+
setPolicyConfig(name: string, config: Record<string, unknown>): void;
|
|
18
|
+
resolve(window: InputWindow, submissions: Submission[]): Promise<Resolution>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=arbitration-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"arbitration-engine.d.ts","sourceRoot":"","sources":["../src/arbitration-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EACrB,WAAW,EACX,UAAU,EACV,UAAU,EACX,MAAM,YAAY,CAAC;AAkBpB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACrC,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,UAAU,CAAC;CACrE;AACD,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7E,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC9D,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5E;AACD,iGAAiG;AACjG,qBAAa,iBAAkB,YAAW,kBAAkB;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwC;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;;IAatE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAS7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAS9D,OAAO,CACX,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,UAAU,EAAE,GACxB,OAAO,CAAC,UAAU,CAAC;CAuDvB"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { BatchPolicy, FIFOPolicy, FirstAcceptedPolicy, HostSelectionPolicy, ParallelBranchesPolicy, PluralityPolicy, RankedPolicy, } from "./policies/index.js";
|
|
2
|
+
import { integer, InteractionError, policyName, record, text, } from "./validation.js";
|
|
3
|
+
/** Pure selection. Persistence and monotonic resolution sequences belong to the orchestrator. */
|
|
4
|
+
export class ArbitrationEngine {
|
|
5
|
+
policies = new Map();
|
|
6
|
+
configs = new Map();
|
|
7
|
+
constructor() {
|
|
8
|
+
for (const policy of [
|
|
9
|
+
new FIFOPolicy(),
|
|
10
|
+
new FirstAcceptedPolicy(),
|
|
11
|
+
new PluralityPolicy(),
|
|
12
|
+
new RankedPolicy(),
|
|
13
|
+
new BatchPolicy(),
|
|
14
|
+
new ParallelBranchesPolicy(),
|
|
15
|
+
new HostSelectionPolicy(),
|
|
16
|
+
])
|
|
17
|
+
this.registerPolicy(policy.name, policy);
|
|
18
|
+
}
|
|
19
|
+
registerPolicy(name, policy) {
|
|
20
|
+
policyName(name);
|
|
21
|
+
if (name !== policy.name || typeof policy.resolve !== "function")
|
|
22
|
+
throw new InteractionError("INVALID_INPUT", "Policy registration name must match policy.name");
|
|
23
|
+
this.policies.set(name, policy);
|
|
24
|
+
}
|
|
25
|
+
setPolicyConfig(name, config) {
|
|
26
|
+
if (!this.policies.has(name))
|
|
27
|
+
throw new InteractionError("INVALID_INPUT", `Unknown arbitration policy: ${name}`);
|
|
28
|
+
record(config, "policy config");
|
|
29
|
+
this.configs.set(name, structuredClone(config));
|
|
30
|
+
}
|
|
31
|
+
async resolve(window, submissions) {
|
|
32
|
+
const policy = this.policies.get(window.arbitrationPolicy);
|
|
33
|
+
if (!policy)
|
|
34
|
+
throw new InteractionError("INVALID_INPUT", `Unknown arbitration policy: ${window.arbitrationPolicy}`);
|
|
35
|
+
integer(window.maxSelectedInputs, "maxSelectedInputs");
|
|
36
|
+
if (new Set(submissions.map((s) => s.id)).size !== submissions.length ||
|
|
37
|
+
submissions.some((s) => s.windowId !== window.id ||
|
|
38
|
+
s.status !== "eligible" ||
|
|
39
|
+
!Number.isSafeInteger(s.admissionSequence) ||
|
|
40
|
+
s.admissionSequence < 1))
|
|
41
|
+
throw new InteractionError("INVALID_INPUT", "Arbitration requires distinct eligible submissions from this window");
|
|
42
|
+
const effective = {
|
|
43
|
+
...window,
|
|
44
|
+
arbitrationPolicyConfig: {
|
|
45
|
+
...this.configs.get(policy.name),
|
|
46
|
+
...window.arbitrationPolicyConfig,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const resolution = policy.resolve(structuredClone(effective), structuredClone(submissions));
|
|
50
|
+
text(resolution.id, "resolution id");
|
|
51
|
+
text(resolution.policyVersion, "policyVersion");
|
|
52
|
+
record(resolution.appMetadata, "resolution metadata");
|
|
53
|
+
const selected = resolution.selectedSubmissionIds;
|
|
54
|
+
const rejected = resolution.rejectedSubmissionIds;
|
|
55
|
+
const all = [...selected, ...rejected];
|
|
56
|
+
if (resolution.windowId !== window.id ||
|
|
57
|
+
resolution.baseCommitHash !== window.baseCommitHash ||
|
|
58
|
+
resolution.arbitrationPolicy !== window.arbitrationPolicy ||
|
|
59
|
+
resolution.status !== "selected" ||
|
|
60
|
+
selected.length > window.maxSelectedInputs ||
|
|
61
|
+
all.length !== submissions.length ||
|
|
62
|
+
new Set(all).size !== all.length ||
|
|
63
|
+
all.some((id) => !submissions.some((s) => s.id === id)))
|
|
64
|
+
throw new InteractionError("INVALID_POLICY_RESULT", "Policy must partition submissions exactly once within capacity");
|
|
65
|
+
return resolution;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { InteractionRepository } from "./db/repository.js";
|
|
2
|
+
import type { EntitlementReservation, InputWindow, PriceQuote, Resolution, Submission } from "./types.js";
|
|
3
|
+
interface Allocation {
|
|
4
|
+
grantId: string;
|
|
5
|
+
amount: number;
|
|
6
|
+
}
|
|
7
|
+
export interface StoredReservation extends EntitlementReservation {
|
|
8
|
+
state: "reserved" | "captured" | "released" | "refunded";
|
|
9
|
+
allocations: Allocation[];
|
|
10
|
+
}
|
|
11
|
+
export interface IBillingController {
|
|
12
|
+
quote(window: InputWindow, actorId: string): Promise<PriceQuote>;
|
|
13
|
+
authorize(submission: Submission): Promise<EntitlementReservation>;
|
|
14
|
+
capture(resolution: Resolution): Promise<void>;
|
|
15
|
+
cancel(submission: Submission): Promise<void>;
|
|
16
|
+
refund(submission: Submission, reason: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/** Credit-backed billing against monetization grants on the same PostgreSQL database.
|
|
19
|
+
* A hold deducts spendable balance once. Capture, release and refund are durable transitions.
|
|
20
|
+
*/
|
|
21
|
+
export declare class BillingController implements IBillingController {
|
|
22
|
+
private readonly repository;
|
|
23
|
+
constructor(repository: InteractionRepository);
|
|
24
|
+
/** Lock every allocation a lifecycle transaction will touch, in database ID order. */
|
|
25
|
+
lockReservations(r: InteractionRepository, submissionIds: string[]): Promise<void>;
|
|
26
|
+
quote(window: InputWindow, actorId: string): Promise<PriceQuote>;
|
|
27
|
+
authorize(submission: Submission): Promise<EntitlementReservation>;
|
|
28
|
+
getReservation(id: string, r?: InteractionRepository): Promise<StoredReservation | null>;
|
|
29
|
+
authorizeInTransaction(r: InteractionRepository, s: Submission, w: InputWindow): Promise<StoredReservation>;
|
|
30
|
+
capture(resolution: Resolution): Promise<void>;
|
|
31
|
+
captureInTransaction(r: InteractionRepository, s: Submission, w: InputWindow): Promise<void>;
|
|
32
|
+
cancel(submission: Submission): Promise<void>;
|
|
33
|
+
refund(submission: Submission, reason: string): Promise<void>;
|
|
34
|
+
releaseInTransaction(r: InteractionRepository, s: Submission, w: InputWindow, reason: string): Promise<void>;
|
|
35
|
+
expireReservations(limit?: number): Promise<number>;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
38
|
+
//# sourceMappingURL=billing-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"billing-controller.d.ts","sourceRoot":"","sources":["../src/billing-controller.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,KAAK,EACV,sBAAsB,EACtB,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,iBAAkB,SAAQ,sBAAsB;IAC/D,KAAK,EAAE,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,CAAC;IACzD,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B;AACD,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACjE,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACnE,OAAO,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AACD;;GAEG;AACH,qBAAa,iBAAkB,YAAW,kBAAkB;IAC9C,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,qBAAqB;IAC9D,sFAAsF;IAChF,gBAAgB,CACpB,CAAC,EAAE,qBAAqB,EACxB,aAAa,EAAE,MAAM,EAAE,GACtB,OAAO,CAAC,IAAI,CAAC;IAoBV,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAmBhE,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAKlE,cAAc,CAClB,EAAE,EAAE,MAAM,EACV,CAAC,wBAAkB,GAClB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAiB9B,sBAAsB,CAC1B,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,GACb,OAAO,CAAC,iBAAiB,CAAC;IA+FvB,OAAO,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAkC9C,oBAAoB,CACxB,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,GACb,OAAO,CAAC,IAAI,CAAC;IAgDV,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAoB7C,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY7D,oBAAoB,CACxB,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,EACd,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC;IAiDV,kBAAkB,CAAC,KAAK,SAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAkCvD"}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { grantAppliesAt } from "@portalshq/monetization";
|
|
3
|
+
import { integer, InteractionError, requireValue, text } from "./validation.js";
|
|
4
|
+
/** Credit-backed billing against monetization grants on the same PostgreSQL database.
|
|
5
|
+
* A hold deducts spendable balance once. Capture, release and refund are durable transitions.
|
|
6
|
+
*/
|
|
7
|
+
export class BillingController {
|
|
8
|
+
repository;
|
|
9
|
+
constructor(repository) {
|
|
10
|
+
this.repository = repository;
|
|
11
|
+
}
|
|
12
|
+
/** Lock every allocation a lifecycle transaction will touch, in database ID order. */
|
|
13
|
+
async lockReservations(r, submissionIds) {
|
|
14
|
+
const allocations = await r.query("SELECT allocations FROM interaction_reservations WHERE submission_id=ANY($1::text[])", [submissionIds]);
|
|
15
|
+
const ids = [
|
|
16
|
+
...new Set(allocations.rows.flatMap((row) => row.allocations.map((allocation) => allocation.grantId))),
|
|
17
|
+
];
|
|
18
|
+
if (ids.length)
|
|
19
|
+
await r.query("SELECT id FROM monetization_entitlements WHERE id=ANY($1::text[]) ORDER BY id FOR UPDATE", [ids]);
|
|
20
|
+
}
|
|
21
|
+
async quote(window, actorId) {
|
|
22
|
+
text(actorId, "actorId");
|
|
23
|
+
const current = requireValue(await this.repository.getWindow(window.id), "Window");
|
|
24
|
+
const terms = current.billingTerms;
|
|
25
|
+
if (current.billingPolicy !== "free" && !terms)
|
|
26
|
+
throw new InteractionError("INVALID_INPUT", "Paid window requires billingTerms");
|
|
27
|
+
return {
|
|
28
|
+
id: randomUUID(),
|
|
29
|
+
amountCents: current.billingPolicy === "free" ? 0 : terms.amountCents,
|
|
30
|
+
currency: terms?.currency ?? "USD",
|
|
31
|
+
expiresAt: new Date((await this.repository.now()) + 900000).toISOString(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async authorize(submission) {
|
|
35
|
+
return this.repository.withSubmission(submission.id, async (r, s, w) => this.authorizeInTransaction(r, s, w));
|
|
36
|
+
}
|
|
37
|
+
async getReservation(id, r = this.repository) {
|
|
38
|
+
const row = (await r.query("SELECT * FROM interaction_reservations WHERE submission_id=$1", [id])).rows[0];
|
|
39
|
+
return row
|
|
40
|
+
? {
|
|
41
|
+
id: row.id,
|
|
42
|
+
amountCredits: row.amount_credits,
|
|
43
|
+
expiresAt: new Date(row.expires_at).toISOString(),
|
|
44
|
+
state: row.state,
|
|
45
|
+
allocations: row.allocations,
|
|
46
|
+
}
|
|
47
|
+
: null;
|
|
48
|
+
}
|
|
49
|
+
async authorizeInTransaction(r, s, w) {
|
|
50
|
+
const existing = await this.getReservation(s.id, r);
|
|
51
|
+
if (existing) {
|
|
52
|
+
if (existing.state === "released" || existing.state === "refunded")
|
|
53
|
+
throw new InteractionError("INVALID_STATE", "Reservation was already released");
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
if (s.status !== "awaiting_entitlement")
|
|
57
|
+
throw new InteractionError("INVALID_STATE", "Submission must pass moderation before billing");
|
|
58
|
+
const allocations = [];
|
|
59
|
+
const now = await r.now();
|
|
60
|
+
let expiresAt = now + (w.billingTerms?.reservationTtlMs ?? 3600000);
|
|
61
|
+
const terms = w.billingPolicy === "free"
|
|
62
|
+
? undefined
|
|
63
|
+
: requireValue(w.billingTerms, "billingTerms");
|
|
64
|
+
if (terms) {
|
|
65
|
+
const site = {
|
|
66
|
+
channelId: w.worldId,
|
|
67
|
+
...(terms.sessionId ? { sessionId: terms.sessionId } : {}),
|
|
68
|
+
...(terms.date ? { date: terms.date } : {}),
|
|
69
|
+
};
|
|
70
|
+
const candidates = await r.query(`SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS created_order
|
|
71
|
+
FROM monetization_entitlements WHERE tenant_id=$1 AND consumer_id=$2 AND kind=$3 AND remaining>0 AND (expires_at IS NULL OR expires_at>clock_timestamp()) ORDER BY id FOR UPDATE`, [terms.tenantId, s.actorId, terms.entitlementKind]);
|
|
72
|
+
const applicable = candidates.rows
|
|
73
|
+
.filter((row) => grantAppliesAt(row.scope, site))
|
|
74
|
+
.sort((a, b) => a.created_order < b.created_order
|
|
75
|
+
? -1
|
|
76
|
+
: a.created_order > b.created_order
|
|
77
|
+
? 1
|
|
78
|
+
: 0);
|
|
79
|
+
if (applicable.reduce((sum, row) => sum + Number(row.remaining), 0) <
|
|
80
|
+
terms.amountCredits)
|
|
81
|
+
throw new InteractionError("INSUFFICIENT_CREDITS", "Insufficient credits");
|
|
82
|
+
let owed = terms.amountCredits;
|
|
83
|
+
for (const row of applicable) {
|
|
84
|
+
if (!owed)
|
|
85
|
+
break;
|
|
86
|
+
const amount = Math.min(owed, Number(row.remaining));
|
|
87
|
+
await r.query("UPDATE monetization_entitlements SET remaining=remaining-$2,updated_at=now() WHERE id=$1", [row.id, amount]);
|
|
88
|
+
allocations.push({ grantId: row.id, amount });
|
|
89
|
+
owed -= amount;
|
|
90
|
+
if (row.expires_at)
|
|
91
|
+
expiresAt = Math.min(expiresAt, new Date(row.expires_at).getTime());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const state = ["capture_if_selected", "pay_to_publish"].includes(w.billingPolicy)
|
|
95
|
+
? "reserved"
|
|
96
|
+
: "captured";
|
|
97
|
+
const reservation = {
|
|
98
|
+
id: randomUUID(),
|
|
99
|
+
state,
|
|
100
|
+
amountCredits: terms?.amountCredits ?? 0,
|
|
101
|
+
allocations,
|
|
102
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
103
|
+
};
|
|
104
|
+
await r.query("INSERT INTO interaction_reservations(id,submission_id,state,amount_credits,allocations,expires_at) VALUES($1,$2,$3,$4,$5,$6)", [
|
|
105
|
+
reservation.id,
|
|
106
|
+
s.id,
|
|
107
|
+
state,
|
|
108
|
+
reservation.amountCredits,
|
|
109
|
+
JSON.stringify(allocations),
|
|
110
|
+
reservation.expiresAt,
|
|
111
|
+
]);
|
|
112
|
+
await r.emit(w, "billing_authorized", {
|
|
113
|
+
submissionId: s.id,
|
|
114
|
+
reservationId: reservation.id,
|
|
115
|
+
state,
|
|
116
|
+
amountCredits: reservation.amountCredits,
|
|
117
|
+
});
|
|
118
|
+
return reservation;
|
|
119
|
+
}
|
|
120
|
+
async capture(resolution) {
|
|
121
|
+
await this.repository.transaction(async (r) => {
|
|
122
|
+
const w = requireValue(await r.getWindow(resolution.windowId, true), "Window");
|
|
123
|
+
const stored = requireValue(await r.getResolution(resolution.id), "Resolution");
|
|
124
|
+
if (stored.windowId !== w.id ||
|
|
125
|
+
!["selected", "executing", "committed"].includes(stored.status))
|
|
126
|
+
throw new InteractionError("INVALID_STATE", "Resolution cannot be captured");
|
|
127
|
+
// Pay-to-publish capture is owned by commitResolution, after output moderation.
|
|
128
|
+
if (w.billingPolicy === "pay_to_publish")
|
|
129
|
+
throw new InteractionError("OUTPUT_REQUIRED", "Use commitResolution to capture after output moderation");
|
|
130
|
+
if (stored.status === "committed")
|
|
131
|
+
return;
|
|
132
|
+
await this.lockReservations(r, stored.selectedSubmissionIds);
|
|
133
|
+
for (const id of stored.selectedSubmissionIds)
|
|
134
|
+
await this.captureInTransaction(r, requireValue(await r.getSubmission(id, true), "Submission"), w);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async captureInTransaction(r, s, w) {
|
|
138
|
+
const reservation = requireValue(await this.getReservation(s.id, r), "Reservation");
|
|
139
|
+
if (reservation.state !== "reserved" && reservation.state !== "captured")
|
|
140
|
+
throw new InteractionError("INVALID_STATE", "Reservation is no longer payable");
|
|
141
|
+
const now = await r.now();
|
|
142
|
+
if (reservation.state === "reserved" &&
|
|
143
|
+
Date.parse(reservation.expiresAt) <= now)
|
|
144
|
+
throw new InteractionError("RESERVATION_EXPIRED", "Credit reservation expired");
|
|
145
|
+
await this.lockReservations(r, [s.id]);
|
|
146
|
+
for (const allocation of [...reservation.allocations].sort((a, b) => a.grantId.localeCompare(b.grantId))) {
|
|
147
|
+
const grant = (await r.query("SELECT id,expires_at FROM monetization_entitlements WHERE id=$1 FOR UPDATE", [allocation.grantId])).rows[0];
|
|
148
|
+
if (!grant ||
|
|
149
|
+
(grant.expires_at && new Date(grant.expires_at).getTime() <= now))
|
|
150
|
+
throw new InteractionError("ENTITLEMENT_REVOKED", "Backing credit grant was revoked or expired");
|
|
151
|
+
}
|
|
152
|
+
if (reservation.state === "captured")
|
|
153
|
+
return;
|
|
154
|
+
await r.query("UPDATE interaction_reservations SET state='captured',updated_at=now() WHERE id=$1", [reservation.id]);
|
|
155
|
+
await r.emit(w, "billing_captured", {
|
|
156
|
+
submissionId: s.id,
|
|
157
|
+
reservationId: reservation.id,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async cancel(submission) {
|
|
161
|
+
await this.repository.withSubmission(submission.id, async (r, s, w) => {
|
|
162
|
+
if (s.status === "cancelled")
|
|
163
|
+
return;
|
|
164
|
+
if (![
|
|
165
|
+
"received",
|
|
166
|
+
"moderating",
|
|
167
|
+
"awaiting_entitlement",
|
|
168
|
+
"eligible",
|
|
169
|
+
"rejected",
|
|
170
|
+
].includes(s.status))
|
|
171
|
+
throw new InteractionError("INVALID_STATE", "Cannot cancel selected or completed billing");
|
|
172
|
+
await this.releaseInTransaction(r, s, w, "cancelled");
|
|
173
|
+
await r.saveSubmission(s, "cancelled");
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async refund(submission, reason) {
|
|
177
|
+
text(reason, "refund reason");
|
|
178
|
+
await this.repository.withSubmission(submission.id, async (r, s, w) => {
|
|
179
|
+
if (s.status === "selected" || s.status === "executing")
|
|
180
|
+
throw new InteractionError("INVALID_STATE", "Fail the active resolution before refunding");
|
|
181
|
+
await this.releaseInTransaction(r, s, w, reason);
|
|
182
|
+
if (s.status !== "refunded")
|
|
183
|
+
await r.saveSubmission(s, "refunded");
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async releaseInTransaction(r, s, w, reason) {
|
|
187
|
+
const reservation = await this.getReservation(s.id, r);
|
|
188
|
+
if (!reservation ||
|
|
189
|
+
reservation.state === "released" ||
|
|
190
|
+
reservation.state === "refunded")
|
|
191
|
+
return;
|
|
192
|
+
// Lock grants in the same order as capture. Deleted or expired grants cannot be recreated.
|
|
193
|
+
await this.lockReservations(r, [s.id]);
|
|
194
|
+
let restored = 0;
|
|
195
|
+
for (const allocation of [...reservation.allocations].sort((a, b) => a.grantId.localeCompare(b.grantId))) {
|
|
196
|
+
const row = (await r.query("SELECT remaining,quantity,expires_at FROM monetization_entitlements WHERE id=$1 FOR UPDATE", [allocation.grantId])).rows[0];
|
|
197
|
+
if (!row ||
|
|
198
|
+
(row.expires_at &&
|
|
199
|
+
new Date(row.expires_at).getTime() <= (await r.now())))
|
|
200
|
+
continue;
|
|
201
|
+
const amount = Math.min(allocation.amount, Number(row.quantity) - Number(row.remaining));
|
|
202
|
+
await r.query("UPDATE monetization_entitlements SET remaining=remaining+$2,updated_at=now() WHERE id=$1", [allocation.grantId, amount]);
|
|
203
|
+
restored += amount;
|
|
204
|
+
}
|
|
205
|
+
const state = reservation.state === "reserved" ? "released" : "refunded";
|
|
206
|
+
await r.query("UPDATE interaction_reservations SET state=$2,reason=$3,updated_at=now() WHERE id=$1", [reservation.id, state, reason]);
|
|
207
|
+
await r.emit(w, "billing_released", {
|
|
208
|
+
submissionId: s.id,
|
|
209
|
+
reservationId: reservation.id,
|
|
210
|
+
state,
|
|
211
|
+
restoredCredits: restored,
|
|
212
|
+
reason,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
async expireReservations(limit = 100) {
|
|
216
|
+
integer(limit, "limit", 1, 1000);
|
|
217
|
+
const rows = await this.repository.query(`SELECT submission_id FROM interaction_reservations r JOIN submissions s ON s.id=r.submission_id
|
|
218
|
+
WHERE r.state='reserved' AND r.expires_at<=clock_timestamp() AND s.status IN ('received','moderating','awaiting_entitlement','eligible') ORDER BY r.expires_at LIMIT $1`, [limit]);
|
|
219
|
+
let expired = 0;
|
|
220
|
+
for (const row of rows.rows)
|
|
221
|
+
await this.repository.withSubmission(row.submission_id, async (r, s, w) => {
|
|
222
|
+
const reservation = await this.getReservation(s.id, r);
|
|
223
|
+
if (reservation?.state !== "reserved" ||
|
|
224
|
+
Date.parse(reservation.expiresAt) > (await r.now()))
|
|
225
|
+
return;
|
|
226
|
+
if (![
|
|
227
|
+
"received",
|
|
228
|
+
"moderating",
|
|
229
|
+
"awaiting_entitlement",
|
|
230
|
+
"eligible",
|
|
231
|
+
].includes(s.status))
|
|
232
|
+
return;
|
|
233
|
+
await this.releaseInTransaction(r, s, w, "reservation_expired");
|
|
234
|
+
await r.saveSubmission(s, "failed");
|
|
235
|
+
expired++;
|
|
236
|
+
});
|
|
237
|
+
return expired;
|
|
238
|
+
}
|
|
239
|
+
}
|