@supersolutions/super-router-storage-postgres 0.0.0 → 0.1.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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/dist/access.d.ts +4 -0
- package/dist/access.js +33 -0
- package/dist/control.d.ts +175 -0
- package/dist/control.js +808 -0
- package/dist/index.d.ts +440 -0
- package/dist/index.js +563 -0
- package/migrations/001_initial.sql +68 -0
- package/migrations/002_integrity.sql +22 -0
- package/migrations/003_membership_subscriptions.sql +64 -0
- package/migrations/004_integration_bindings.sql +4 -0
- package/migrations/005_checkouts.sql +9 -0
- package/package.json +31 -7
- package/README.md +0 -3
package/dist/control.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import { RouterError, roleScopes, MemberInput, CustomerInput, KeyInput, PlanInput, SubscriptionInput, PlanChangeInput, PaymentInput, CreditGrantInput, } from "@supersolutions/super-router-contracts";
|
|
3
|
+
import { keyPrincipal, revalidate } from "./access.js";
|
|
4
|
+
const scope = (p) => [p.workspace, p.environment];
|
|
5
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
6
|
+
function canonical(v) {
|
|
7
|
+
if (Array.isArray(v))
|
|
8
|
+
return "[" + v.map(canonical).join(",") + "]";
|
|
9
|
+
if (v && typeof v === "object")
|
|
10
|
+
return ("{" +
|
|
11
|
+
Object.entries(v)
|
|
12
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
13
|
+
.map(([k, x]) => JSON.stringify(k) + ":" + canonical(x))
|
|
14
|
+
.join(",") +
|
|
15
|
+
"}");
|
|
16
|
+
return JSON.stringify(v);
|
|
17
|
+
}
|
|
18
|
+
const day = 86400000;
|
|
19
|
+
export class ControlPlane {
|
|
20
|
+
store;
|
|
21
|
+
integrations;
|
|
22
|
+
clock;
|
|
23
|
+
constructor(store, integrations = [], clock = () => new Date()) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.integrations = integrations;
|
|
26
|
+
this.clock = clock;
|
|
27
|
+
if (new Set(integrations.map((i) => i.id)).size !== integrations.length)
|
|
28
|
+
throw new Error("duplicate integration");
|
|
29
|
+
for (const i of integrations)
|
|
30
|
+
if (i.mode === "sandbox" &&
|
|
31
|
+
(i.secret.length < 32 || i.environment !== "test"))
|
|
32
|
+
throw new Error("Sandbox payment ingress requires test environment and >=32-character secret");
|
|
33
|
+
}
|
|
34
|
+
async tx(p, fn, permission) {
|
|
35
|
+
const c = await this.store.pool.connect();
|
|
36
|
+
try {
|
|
37
|
+
await c.query("BEGIN");
|
|
38
|
+
await c.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [
|
|
39
|
+
JSON.stringify(scope(p)),
|
|
40
|
+
]);
|
|
41
|
+
if (permission)
|
|
42
|
+
await revalidate(c, p, permission);
|
|
43
|
+
const result = await fn(c);
|
|
44
|
+
await c.query("COMMIT");
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
await c.query("ROLLBACK");
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
c.release();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async audit(c, p, action, resource, details = {}) {
|
|
56
|
+
await c.query("INSERT INTO sr_audit(id,workspace,environment,actor,action,resource,details) VALUES($1,$2,$3,$4,$5,$6,$7)", [
|
|
57
|
+
randomUUID(),
|
|
58
|
+
...scope(p),
|
|
59
|
+
p.member_id ?? "trusted-host",
|
|
60
|
+
action,
|
|
61
|
+
resource,
|
|
62
|
+
details,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
async command(p, permission, operation, key, input, fn, authorize) {
|
|
66
|
+
if (!key || key.length > 200)
|
|
67
|
+
throw new RouterError("missing_idempotency_key");
|
|
68
|
+
return this.tx(p, async (c) => {
|
|
69
|
+
if (authorize)
|
|
70
|
+
await authorize(c);
|
|
71
|
+
const digest = hash(canonical(input));
|
|
72
|
+
const prior = await c.query("SELECT * FROM sr_commands WHERE workspace=$1 AND environment=$2 AND operation=$3 AND key=$4", [...scope(p), operation, key]);
|
|
73
|
+
if (prior.rows[0]) {
|
|
74
|
+
if (prior.rows[0].digest !== digest)
|
|
75
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
76
|
+
return prior.rows[0].result;
|
|
77
|
+
}
|
|
78
|
+
const result = await fn(c);
|
|
79
|
+
const saved = "token" in result ? { ...result, token: null } : result;
|
|
80
|
+
await c.query("INSERT INTO sr_commands VALUES($1,$2,$3,$4,$5,$6)", [
|
|
81
|
+
...scope(p),
|
|
82
|
+
operation,
|
|
83
|
+
key,
|
|
84
|
+
digest,
|
|
85
|
+
saved,
|
|
86
|
+
]);
|
|
87
|
+
return result;
|
|
88
|
+
}, permission);
|
|
89
|
+
}
|
|
90
|
+
/** Host-only bootstrap; never exposed over HTTP. Refuses to replace any existing membership. */
|
|
91
|
+
async bootstrap(workspace, environment, member = "owner", suppliedToken) {
|
|
92
|
+
if (suppliedToken && !/^sr_[a-f0-9]{64}$/.test(suppliedToken))
|
|
93
|
+
throw new RouterError("invalid_bootstrap_key");
|
|
94
|
+
const p = {
|
|
95
|
+
workspace,
|
|
96
|
+
environment,
|
|
97
|
+
customer: "_admin",
|
|
98
|
+
member_id: member,
|
|
99
|
+
scopes: [...roleScopes.owner],
|
|
100
|
+
};
|
|
101
|
+
return this.tx(p, async (c) => {
|
|
102
|
+
const existing = await c.query("SELECT 1 FROM sr_members WHERE workspace=$1 AND environment=$2", scope(p));
|
|
103
|
+
if (existing.rowCount)
|
|
104
|
+
throw new RouterError("already_bootstrapped", 409);
|
|
105
|
+
await c.query("INSERT INTO sr_members(workspace,environment,id,role) VALUES($1,$2,$3,$4)", [...scope(p), member, "owner"]);
|
|
106
|
+
const key = await this.insertKey(c, p, member, "_admin", [...roleScopes.owner], undefined, new Date(this.clock().getTime() + 30 * day), suppliedToken);
|
|
107
|
+
await this.audit(c, p, "workspace.bootstrap", member);
|
|
108
|
+
return key;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async authenticate(token) {
|
|
112
|
+
if (!/^sr_[a-f0-9]{64}$/.test(token))
|
|
113
|
+
throw new RouterError("invalid_key", 401);
|
|
114
|
+
return keyPrincipal(this.store.pool, hash(token));
|
|
115
|
+
}
|
|
116
|
+
async insertKey(c, p, member, customer, scopes, services, expires, suppliedToken) {
|
|
117
|
+
const id = randomUUID(), token = suppliedToken ?? "sr_" + randomBytes(32).toString("hex");
|
|
118
|
+
await c.query("INSERT INTO sr_keys(workspace,environment,id,member_id,customer,hash,scopes,service_ids,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", [
|
|
119
|
+
...scope(p),
|
|
120
|
+
id,
|
|
121
|
+
member,
|
|
122
|
+
customer,
|
|
123
|
+
hash(token),
|
|
124
|
+
scopes,
|
|
125
|
+
services ?? null,
|
|
126
|
+
expires,
|
|
127
|
+
]);
|
|
128
|
+
return { id, token, expires_at: expires.toISOString() };
|
|
129
|
+
}
|
|
130
|
+
async member(p, raw, key) {
|
|
131
|
+
const input = MemberInput.parse(raw);
|
|
132
|
+
return this.command(p, "identity:write", "member", key, input, async (c) => {
|
|
133
|
+
const existing = await c.query("SELECT role,active FROM sr_members WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), input.id]);
|
|
134
|
+
if (existing.rows[0]?.role === "owner" &&
|
|
135
|
+
existing.rows[0].active &&
|
|
136
|
+
(input.role !== "owner" || !input.active)) {
|
|
137
|
+
const others = await c.query("SELECT 1 FROM sr_members WHERE workspace=$1 AND environment=$2 AND role='owner' AND active=true AND id<>$3", [...scope(p), input.id]);
|
|
138
|
+
if (!others.rowCount)
|
|
139
|
+
throw new RouterError("last_owner", 409);
|
|
140
|
+
}
|
|
141
|
+
await c.query("INSERT INTO sr_members VALUES($1,$2,$3,$4,$5) ON CONFLICT(workspace,environment,id) DO UPDATE SET role=excluded.role,active=excluded.active", [...scope(p), input.id, input.role, input.active]);
|
|
142
|
+
await this.audit(c, p, "member.updated", input.id, {
|
|
143
|
+
role: input.role,
|
|
144
|
+
active: input.active,
|
|
145
|
+
});
|
|
146
|
+
return input;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async customer(p, raw, key) {
|
|
150
|
+
const input = CustomerInput.parse(raw);
|
|
151
|
+
return this.command(p, "billing:write", "customer", key, input, async (c) => {
|
|
152
|
+
const r = await c.query("SELECT external_id FROM sr_customers WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), input.id]);
|
|
153
|
+
if (r.rows[0] && r.rows[0].external_id !== input.external_id)
|
|
154
|
+
throw new RouterError("customer_conflict", 409);
|
|
155
|
+
await c.query("INSERT INTO sr_customers VALUES($1,$2,$3,$4) ON CONFLICT(workspace,environment,id) DO NOTHING", [...scope(p), input.id, input.external_id]);
|
|
156
|
+
await this.audit(c, p, "customer.created", input.id);
|
|
157
|
+
return input;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async issueKey(p, raw, key) {
|
|
161
|
+
const input = KeyInput.parse(raw);
|
|
162
|
+
return this.command(p, "identity:write", "key.issue", key, input, async (c) => {
|
|
163
|
+
const member = await c.query("SELECT * FROM sr_members WHERE workspace=$1 AND environment=$2 AND id=$3 AND active=true", [...scope(p), input.member_id]);
|
|
164
|
+
if (!member.rows[0])
|
|
165
|
+
throw new RouterError("resource_not_found", 404);
|
|
166
|
+
if (input.scopes.some((s) => !roleScopes[member.rows[0].role].includes(s)))
|
|
167
|
+
throw new RouterError("scope_escalation", 403);
|
|
168
|
+
if (member.rows[0].role === "app" && !input.service_ids)
|
|
169
|
+
throw new RouterError("service_scope_required");
|
|
170
|
+
if (input.customer_id !== "_admin")
|
|
171
|
+
await this.requireCustomer(c, p, input.customer_id);
|
|
172
|
+
else if (member.rows[0].role === "app")
|
|
173
|
+
throw new RouterError("customer_required");
|
|
174
|
+
if (input.service_ids)
|
|
175
|
+
await this.requireServices(c, p, input.service_ids);
|
|
176
|
+
const expires = new Date(input.expires_at);
|
|
177
|
+
if (expires <= this.clock() ||
|
|
178
|
+
expires.getTime() > this.clock().getTime() + 366 * day)
|
|
179
|
+
throw new RouterError("invalid_expiry");
|
|
180
|
+
const result = await this.insertKey(c, p, input.member_id, input.customer_id, input.scopes, input.service_ids, expires);
|
|
181
|
+
await this.audit(c, p, "key.issued", result.id, {
|
|
182
|
+
member_id: input.member_id,
|
|
183
|
+
scopes: input.scopes,
|
|
184
|
+
});
|
|
185
|
+
return result;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async revokeKey(p, id, key) {
|
|
189
|
+
return this.command(p, "identity:write", "key.revoke", key, { id }, async (c) => {
|
|
190
|
+
const r = await c.query("UPDATE sr_keys SET revoked_at=COALESCE(revoked_at,now()) WHERE workspace=$1 AND environment=$2 AND id=$3 RETURNING id", [...scope(p), id]);
|
|
191
|
+
if (!r.rowCount)
|
|
192
|
+
throw new RouterError("resource_not_found", 404);
|
|
193
|
+
await this.audit(c, p, "key.revoked", id);
|
|
194
|
+
return { id, revoked: true };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async rotateKey(p, id, key) {
|
|
198
|
+
return this.command(p, "identity:write", "key.rotate", key, { id }, async (c) => {
|
|
199
|
+
const r = await c.query("SELECT * FROM sr_keys WHERE workspace=$1 AND environment=$2 AND id=$3 AND revoked_at IS NULL AND expires_at>now()", [...scope(p), id]);
|
|
200
|
+
const old = r.rows[0];
|
|
201
|
+
if (!old)
|
|
202
|
+
throw new RouterError("resource_not_found", 404);
|
|
203
|
+
const result = await this.insertKey(c, p, old.member_id, old.customer, old.scopes, old.service_ids ?? undefined, old.expires_at);
|
|
204
|
+
await c.query("UPDATE sr_keys SET revoked_at=now() WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id]);
|
|
205
|
+
await this.audit(c, p, "key.rotated", id, {
|
|
206
|
+
replacement_id: result.id,
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async requireCustomer(c, p, id) {
|
|
212
|
+
const r = await c.query("SELECT 1 FROM sr_customers WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id]);
|
|
213
|
+
if (!r.rowCount)
|
|
214
|
+
throw new RouterError("resource_not_found", 404);
|
|
215
|
+
}
|
|
216
|
+
async requireServices(c, p, ids, asset) {
|
|
217
|
+
for (const id of ids) {
|
|
218
|
+
if (p.service_ids && !p.service_ids.includes(id))
|
|
219
|
+
throw new RouterError("scope_denied", 403);
|
|
220
|
+
const r = await c.query("SELECT config FROM sr_services WHERE workspace=$1 AND environment=$2 AND id=$3 AND active=true", [...scope(p), id]);
|
|
221
|
+
if (!r.rows[0])
|
|
222
|
+
throw new RouterError("resource_not_found", 404);
|
|
223
|
+
if (asset && r.rows[0].config.price.asset !== asset)
|
|
224
|
+
throw new RouterError("asset_mismatch", 422);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async publishPlan(p, raw, key) {
|
|
228
|
+
const input = PlanInput.parse(raw);
|
|
229
|
+
return this.command(p, "billing:write", "plan.publish", key, input, async (c) => {
|
|
230
|
+
await this.requireServices(c, p, input.service_ids, input.asset);
|
|
231
|
+
const exists = await c.query("SELECT config FROM sr_plans WHERE workspace=$1 AND environment=$2 AND id=$3 AND version=$4", [...scope(p), input.id, input.version]);
|
|
232
|
+
if (exists.rows[0]) {
|
|
233
|
+
if (canonical(exists.rows[0].config) !== canonical(input))
|
|
234
|
+
throw new RouterError("version_exists", 409);
|
|
235
|
+
return input;
|
|
236
|
+
}
|
|
237
|
+
await c.query("INSERT INTO sr_plans VALUES($1,$2,$3,$4,$5)", [
|
|
238
|
+
...scope(p),
|
|
239
|
+
input.id,
|
|
240
|
+
input.version,
|
|
241
|
+
input,
|
|
242
|
+
]);
|
|
243
|
+
await this.audit(c, p, "plan.published", input.id, {
|
|
244
|
+
version: input.version,
|
|
245
|
+
});
|
|
246
|
+
return input;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async plan(c, p, id, version) {
|
|
250
|
+
const r = await c.query("SELECT config FROM sr_plans WHERE workspace=$1 AND environment=$2 AND id=$3 AND version=$4", [...scope(p), id, version]);
|
|
251
|
+
if (!r.rows[0])
|
|
252
|
+
throw new RouterError("resource_not_found", 404);
|
|
253
|
+
return PlanInput.parse(r.rows[0].config);
|
|
254
|
+
}
|
|
255
|
+
async subscribe(p, raw, key) {
|
|
256
|
+
const input = SubscriptionInput.parse(raw);
|
|
257
|
+
return this.command(p, "billing:write", "subscription.create", key, input, async (c) => {
|
|
258
|
+
await this.requireCustomer(c, p, input.customer_id);
|
|
259
|
+
const plan = await this.plan(c, p, input.plan_id, input.plan_version);
|
|
260
|
+
const id = randomUUID();
|
|
261
|
+
await c.query("INSERT INTO sr_subscriptions(workspace,environment,id,customer,plan_id,plan_version,anchor_at,period_days) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [
|
|
262
|
+
...scope(p),
|
|
263
|
+
id,
|
|
264
|
+
input.customer_id,
|
|
265
|
+
plan.id,
|
|
266
|
+
plan.version,
|
|
267
|
+
this.clock(),
|
|
268
|
+
plan.period_days,
|
|
269
|
+
]);
|
|
270
|
+
await this.audit(c, p, "subscription.created", id);
|
|
271
|
+
return { id, status: "pending" };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async subscription(c, p, id) {
|
|
275
|
+
const r = await c.query("SELECT * FROM sr_subscriptions WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id]);
|
|
276
|
+
if (!r.rows[0])
|
|
277
|
+
throw new RouterError("resource_not_found", 404);
|
|
278
|
+
return r.rows[0];
|
|
279
|
+
}
|
|
280
|
+
async schedulePlan(p, id, raw, key) {
|
|
281
|
+
const input = PlanChangeInput.parse(raw);
|
|
282
|
+
return this.command(p, "billing:write", "subscription.plan", key, { id, ...input }, async (c) => {
|
|
283
|
+
const sub = await this.subscription(c, p, id);
|
|
284
|
+
if (sub.cancel_effective_at)
|
|
285
|
+
throw new RouterError("subscription_cancelled", 409);
|
|
286
|
+
const plan = await this.plan(c, p, input.plan_id, input.plan_version);
|
|
287
|
+
if (plan.period_days !== sub.period_days)
|
|
288
|
+
throw new RouterError("period_change_not_supported", 422);
|
|
289
|
+
const pending = await c.query("SELECT 1 FROM sr_payment_orders WHERE workspace=$1 AND environment=$2 AND subscription=$3 AND cycle>$4", [...scope(p), id, sub.paid_cycle]);
|
|
290
|
+
if (pending.rowCount)
|
|
291
|
+
throw new RouterError("order_already_issued", 409);
|
|
292
|
+
await c.query("UPDATE sr_subscriptions SET next_plan_id=$4,next_plan_version=$5 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, plan.id, plan.version]);
|
|
293
|
+
await this.audit(c, p, "subscription.plan_scheduled", id, {
|
|
294
|
+
plan_id: plan.id,
|
|
295
|
+
version: plan.version,
|
|
296
|
+
});
|
|
297
|
+
return { id, next_plan_id: plan.id, next_plan_version: plan.version };
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async cancelSubscription(p, id, key) {
|
|
301
|
+
return this.command(p, "billing:write", "subscription.cancel", key, { id }, async (c) => {
|
|
302
|
+
const sub = await this.subscription(c, p, id);
|
|
303
|
+
const effective = sub.cancel_effective_at ??
|
|
304
|
+
(sub.current_period_end && sub.current_period_end > this.clock()
|
|
305
|
+
? sub.current_period_end
|
|
306
|
+
: this.clock());
|
|
307
|
+
await c.query("UPDATE sr_subscriptions SET cancel_effective_at=$4,status=CASE WHEN $4::timestamptz<=$5 THEN 'cancelled' ELSE status END WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, effective, this.clock()]);
|
|
308
|
+
await this.audit(c, p, "subscription.cancelled", id, {
|
|
309
|
+
effective_at: effective.toISOString(),
|
|
310
|
+
});
|
|
311
|
+
return { id, cancel_effective_at: effective.toISOString() };
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async createOrder(p, id, integrationId, key) {
|
|
315
|
+
return this.command(p, "billing:write", "order.create", key, { id, integrationId }, async (c) => {
|
|
316
|
+
return this.createOrderTx(c, p, id, integrationId);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
async createOrderTx(c, p, id, integrationId) {
|
|
320
|
+
const integration = this.integrations.find((i) => i.id === integrationId &&
|
|
321
|
+
i.workspace === p.workspace &&
|
|
322
|
+
i.environment === p.environment);
|
|
323
|
+
if (!integration)
|
|
324
|
+
throw new RouterError("resource_not_found", 404);
|
|
325
|
+
const sub = await this.subscription(c, p, id), now = this.clock();
|
|
326
|
+
if (sub.cancel_effective_at && sub.cancel_effective_at <= now)
|
|
327
|
+
throw new RouterError("subscription_cancelled", 409);
|
|
328
|
+
const cycle = Math.floor((now.getTime() - sub.anchor_at.getTime()) / (sub.period_days * day));
|
|
329
|
+
if (cycle < 0 || cycle <= sub.paid_cycle)
|
|
330
|
+
throw new RouterError("period_already_paid", 409);
|
|
331
|
+
const existing = await c.query("SELECT * FROM sr_payment_orders WHERE workspace=$1 AND environment=$2 AND subscription=$3 AND cycle=$4", [...scope(p), id, cycle]);
|
|
332
|
+
if (existing.rows[0]) {
|
|
333
|
+
if (existing.rows[0].integration !== integrationId)
|
|
334
|
+
throw new RouterError("order_integration_conflict", 409);
|
|
335
|
+
return this.orderView(existing.rows[0]);
|
|
336
|
+
}
|
|
337
|
+
const plan = await this.plan(c, p, sub.next_plan_id ?? sub.plan_id, sub.next_plan_version ?? sub.plan_version);
|
|
338
|
+
const start = new Date(sub.anchor_at.getTime() + cycle * sub.period_days * day), end = new Date(start.getTime() + sub.period_days * day);
|
|
339
|
+
const order = randomUUID();
|
|
340
|
+
const r = await c.query("INSERT INTO sr_payment_orders(workspace,environment,id,subscription,cycle,integration,amount_minor,currency,plan,period_start,period_end) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *", [
|
|
341
|
+
...scope(p),
|
|
342
|
+
order,
|
|
343
|
+
id,
|
|
344
|
+
cycle,
|
|
345
|
+
integration.id,
|
|
346
|
+
plan.amount_minor,
|
|
347
|
+
plan.currency,
|
|
348
|
+
plan,
|
|
349
|
+
start,
|
|
350
|
+
end,
|
|
351
|
+
]);
|
|
352
|
+
await this.audit(c, p, "payment_order.created", order, {
|
|
353
|
+
subscription: id,
|
|
354
|
+
cycle,
|
|
355
|
+
});
|
|
356
|
+
return this.orderView(r.rows[0]);
|
|
357
|
+
}
|
|
358
|
+
/** Customer use cases: host authorization and customer ownership are checked in the same transaction. */
|
|
359
|
+
async customerContext(c, host, customer) {
|
|
360
|
+
await revalidate(c, customer, "credits:read");
|
|
361
|
+
if (customer.workspace !== host.workspace ||
|
|
362
|
+
customer.environment !== host.environment ||
|
|
363
|
+
customer.customer.startsWith("_") ||
|
|
364
|
+
customer.scopes.some((s) => s.startsWith("billing:") || s === "identity:write"))
|
|
365
|
+
throw new RouterError("scope_denied", 403);
|
|
366
|
+
await this.requireCustomer(c, host, customer.customer);
|
|
367
|
+
}
|
|
368
|
+
async customerCheckout(host, customer, raw, integration, key) {
|
|
369
|
+
if (!key || key.length > 160)
|
|
370
|
+
throw new RouterError("missing_idempotency_key");
|
|
371
|
+
const offer = PlanChangeInput.parse(raw);
|
|
372
|
+
return this.command(host, "billing:write", "customer.checkout", hash(JSON.stringify([customer.customer, key])), { customer: customer.customer, ...offer, integration }, async (c) => {
|
|
373
|
+
await this.customerContext(c, host, customer);
|
|
374
|
+
const plan = await this.plan(c, host, offer.plan_id, offer.plan_version);
|
|
375
|
+
if (customer.service_ids &&
|
|
376
|
+
plan.service_ids.some((id) => !customer.service_ids.includes(id)))
|
|
377
|
+
throw new RouterError("scope_denied", 403);
|
|
378
|
+
const existing = await c.query("SELECT * FROM sr_subscriptions WHERE workspace=$1 AND environment=$2 AND customer=$3 AND (cancel_effective_at IS NULL OR cancel_effective_at>$4) ORDER BY anchor_at DESC LIMIT 1", [...scope(host), customer.customer, this.clock()]);
|
|
379
|
+
let sub = existing.rows[0];
|
|
380
|
+
if (sub &&
|
|
381
|
+
(sub.plan_id !== plan.id || sub.plan_version !== plan.version))
|
|
382
|
+
throw new RouterError("use_plan_change", 409);
|
|
383
|
+
if (!sub) {
|
|
384
|
+
const created = await c.query("INSERT INTO sr_subscriptions(workspace,environment,id,customer,plan_id,plan_version,anchor_at,period_days) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *", [
|
|
385
|
+
...scope(host),
|
|
386
|
+
randomUUID(),
|
|
387
|
+
customer.customer,
|
|
388
|
+
plan.id,
|
|
389
|
+
plan.version,
|
|
390
|
+
this.clock(),
|
|
391
|
+
plan.period_days,
|
|
392
|
+
]);
|
|
393
|
+
sub = created.rows[0];
|
|
394
|
+
await this.audit(c, host, "subscription.created", sub.id, {
|
|
395
|
+
customer: customer.customer,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return this.createOrderTx(c, host, sub.id, integration);
|
|
399
|
+
}, (c) => this.customerContext(c, host, customer));
|
|
400
|
+
}
|
|
401
|
+
async customerOrder(host, customer, id) {
|
|
402
|
+
return this.tx(host, async (c) => {
|
|
403
|
+
await this.customerContext(c, host, customer);
|
|
404
|
+
const r = await c.query("SELECT o.* FROM sr_payment_orders o JOIN sr_subscriptions s ON (o.workspace,o.environment,o.subscription)=(s.workspace,s.environment,s.id) WHERE o.workspace=$1 AND o.environment=$2 AND o.id=$3 AND s.customer=$4", [...scope(host), id, customer.customer]);
|
|
405
|
+
if (!r.rows[0])
|
|
406
|
+
throw new RouterError("resource_not_found", 404);
|
|
407
|
+
const o = this.orderView(r.rows[0]);
|
|
408
|
+
return {
|
|
409
|
+
...o,
|
|
410
|
+
payment_status: o.state,
|
|
411
|
+
fulfillment_status: o.state === "succeeded"
|
|
412
|
+
? "fulfilled"
|
|
413
|
+
: o.state === "review_required"
|
|
414
|
+
? "review-required"
|
|
415
|
+
: "pending",
|
|
416
|
+
};
|
|
417
|
+
}, "billing:read");
|
|
418
|
+
}
|
|
419
|
+
async customerBilling(host, customer) {
|
|
420
|
+
const data = await this.tx(host, async (c) => {
|
|
421
|
+
await this.customerContext(c, host, customer);
|
|
422
|
+
const subscriptions = await c.query("SELECT id,plan_id,plan_version,next_plan_id,next_plan_version,status,current_period_end,cancel_effective_at,period_days FROM sr_subscriptions WHERE workspace=$1 AND environment=$2 AND customer=$3 ORDER BY anchor_at DESC", [...scope(host), customer.customer]);
|
|
423
|
+
const entitlements = await c.query("SELECT subscription,service_ids,period_start,period_end FROM sr_entitlements WHERE workspace=$1 AND environment=$2 AND customer=$3 AND period_start<=$4 AND period_end>$4", [...scope(host), customer.customer, this.clock()]);
|
|
424
|
+
const orders = await c.query("SELECT o.* FROM sr_payment_orders o JOIN sr_subscriptions s ON (o.workspace,o.environment,o.subscription)=(s.workspace,s.environment,s.id) WHERE o.workspace=$1 AND o.environment=$2 AND s.customer=$3 ORDER BY o.period_start DESC LIMIT 20", [...scope(host), customer.customer]);
|
|
425
|
+
const usage = await c.query("SELECT r.id,r.service->>'id' AS service,r.execution,r.billing,r.created_at,COALESCE((SELECT SUM(s.amount)::text FROM sr_settlements s WHERE (s.workspace,s.environment,s.request)=(r.workspace,r.environment,r.id) AND s.kind='customer_sale'),'0') AS charged FROM sr_requests r WHERE r.workspace=$1 AND r.environment=$2 AND r.customer=$3 ORDER BY r.created_at DESC LIMIT 20", [...scope(host), customer.customer]);
|
|
426
|
+
return {
|
|
427
|
+
subscriptions: subscriptions.rows.map((s) => ({
|
|
428
|
+
...s,
|
|
429
|
+
status: s.cancel_effective_at && s.cancel_effective_at <= this.clock()
|
|
430
|
+
? "cancelled"
|
|
431
|
+
: s.current_period_end && s.current_period_end <= this.clock()
|
|
432
|
+
? "past_due"
|
|
433
|
+
: s.status,
|
|
434
|
+
})),
|
|
435
|
+
entitlements: entitlements.rows,
|
|
436
|
+
orders: orders.rows.map((o) => this.orderView(o)),
|
|
437
|
+
usage: usage.rows,
|
|
438
|
+
};
|
|
439
|
+
}, "billing:read");
|
|
440
|
+
return {
|
|
441
|
+
...data,
|
|
442
|
+
buckets: await this.buckets(customer),
|
|
443
|
+
as_of: this.clock().toISOString(),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
async customerCancel(host, customer, id, key) {
|
|
447
|
+
if (!key || key.length > 160)
|
|
448
|
+
throw new RouterError("missing_idempotency_key");
|
|
449
|
+
return this.command(host, "billing:write", "customer.cancel", hash(JSON.stringify([customer.customer, key])), { id, customer: customer.customer }, async (c) => {
|
|
450
|
+
await this.customerContext(c, host, customer);
|
|
451
|
+
const sub = await this.subscription(c, host, id);
|
|
452
|
+
if (sub.customer !== customer.customer)
|
|
453
|
+
throw new RouterError("resource_not_found", 404);
|
|
454
|
+
const effective = sub.cancel_effective_at ??
|
|
455
|
+
(sub.current_period_end && sub.current_period_end > this.clock()
|
|
456
|
+
? sub.current_period_end
|
|
457
|
+
: this.clock());
|
|
458
|
+
await c.query("UPDATE sr_subscriptions SET cancel_effective_at=$4,status=CASE WHEN $4::timestamptz<=$5 THEN 'cancelled' ELSE status END WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(host), id, effective, this.clock()]);
|
|
459
|
+
await this.audit(c, host, "subscription.cancelled", id, {
|
|
460
|
+
customer: customer.customer,
|
|
461
|
+
effective_at: effective.toISOString(),
|
|
462
|
+
});
|
|
463
|
+
return { id, cancel_effective_at: effective.toISOString() };
|
|
464
|
+
}, (c) => this.customerContext(c, host, customer));
|
|
465
|
+
}
|
|
466
|
+
orderView(o) {
|
|
467
|
+
return {
|
|
468
|
+
id: o.id,
|
|
469
|
+
subscription_id: o.subscription,
|
|
470
|
+
cycle: o.cycle,
|
|
471
|
+
integration: o.integration,
|
|
472
|
+
amount_minor: o.amount_minor,
|
|
473
|
+
currency: o.currency,
|
|
474
|
+
state: o.state,
|
|
475
|
+
period_start: o.period_start.toISOString(),
|
|
476
|
+
period_end: o.period_end.toISOString(),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Signed sandbox payment ingress. No bearer key can assert a payment succeeded. */
|
|
480
|
+
async payment(integrationId, raw, timestamp, signature) {
|
|
481
|
+
const integration = this.integrations.find((i) => i.id === integrationId);
|
|
482
|
+
if (!integration || integration.mode !== "sandbox")
|
|
483
|
+
throw new RouterError("resource_not_found", 404);
|
|
484
|
+
if (!/^\d{10}$/.test(timestamp) ||
|
|
485
|
+
Math.abs(this.clock().getTime() / 1000 - Number(timestamp)) > 300 ||
|
|
486
|
+
!/^v1=[a-f0-9]{64}$/.test(signature))
|
|
487
|
+
throw new RouterError("invalid_signature", 401);
|
|
488
|
+
const expected = createHmac("sha256", integration.secret)
|
|
489
|
+
.update(timestamp + ".")
|
|
490
|
+
.update(raw)
|
|
491
|
+
.digest();
|
|
492
|
+
if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), "hex")))
|
|
493
|
+
throw new RouterError("invalid_signature", 401);
|
|
494
|
+
let json;
|
|
495
|
+
try {
|
|
496
|
+
json = JSON.parse(raw.toString());
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
throw new RouterError("invalid_json");
|
|
500
|
+
}
|
|
501
|
+
return this.settle(integration, PaymentInput.parse(json));
|
|
502
|
+
}
|
|
503
|
+
async settle(integration, event, receipt) {
|
|
504
|
+
const p = {
|
|
505
|
+
workspace: integration.workspace,
|
|
506
|
+
environment: integration.environment,
|
|
507
|
+
customer: "_system",
|
|
508
|
+
member_id: "payment:" + integration.id,
|
|
509
|
+
scopes: [],
|
|
510
|
+
};
|
|
511
|
+
return this.tx(p, async (c) => {
|
|
512
|
+
await c.query("INSERT INTO sr_integration_bindings VALUES($1,$2,$3) ON CONFLICT DO NOTHING", [integration.id, ...scope(p)]);
|
|
513
|
+
const binding = await c.query("SELECT 1 FROM sr_integration_bindings WHERE id=$1 AND workspace=$2 AND environment=$3", [integration.id, ...scope(p)]);
|
|
514
|
+
if (!binding.rowCount)
|
|
515
|
+
throw new RouterError("integration_binding_conflict", 409);
|
|
516
|
+
const digest = hash(canonical(event));
|
|
517
|
+
const prior = await c.query("SELECT * FROM sr_payment_events WHERE integration=$1 AND event_id=$2", [integration.id, event.id]);
|
|
518
|
+
if (prior.rows[0]) {
|
|
519
|
+
if (prior.rows[0].digest !== digest)
|
|
520
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
521
|
+
return { accepted: true, outcome: prior.rows[0].outcome };
|
|
522
|
+
}
|
|
523
|
+
const r = await c.query("SELECT * FROM sr_payment_orders WHERE workspace=$1 AND environment=$2 AND id=$3 AND integration=$4", [...scope(p), event.order_id, integration.id]);
|
|
524
|
+
const order = r.rows[0];
|
|
525
|
+
if (!order)
|
|
526
|
+
throw new RouterError("resource_not_found", 404);
|
|
527
|
+
if (order.amount_minor !== event.amount_minor ||
|
|
528
|
+
order.currency !== event.currency)
|
|
529
|
+
throw new RouterError("payment_amount_mismatch", 422);
|
|
530
|
+
if (receipt) {
|
|
531
|
+
const checkout = await c.query("SELECT reference,identity FROM sr_checkouts WHERE workspace=$1 AND environment=$2 AND order_id=$3", [...scope(p), order.id]);
|
|
532
|
+
if (integration.mode !== "hosted" ||
|
|
533
|
+
checkout.rows[0]?.reference !== receipt.reference ||
|
|
534
|
+
checkout.rows[0]?.identity !== integration.adapter.identity)
|
|
535
|
+
throw new RouterError("payment_reference_mismatch", 409);
|
|
536
|
+
}
|
|
537
|
+
const sub = await this.subscription(c, p, order.subscription);
|
|
538
|
+
const now = this.clock();
|
|
539
|
+
let outcome = "ignored";
|
|
540
|
+
if (order.state !== "succeeded" &&
|
|
541
|
+
order.state !== "review_required" &&
|
|
542
|
+
event.revision > order.revision) {
|
|
543
|
+
if (event.status === "failed") {
|
|
544
|
+
outcome = "failed";
|
|
545
|
+
await c.query("UPDATE sr_payment_orders SET state='failed',revision=$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), order.id, event.revision]);
|
|
546
|
+
if (!sub.current_period_end || sub.current_period_end <= now)
|
|
547
|
+
await c.query("UPDATE sr_subscriptions SET status=CASE WHEN cancel_effective_at<=$4 THEN 'cancelled' ELSE 'past_due' END WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), sub.id, now]);
|
|
548
|
+
}
|
|
549
|
+
else if (order.period_end <= now ||
|
|
550
|
+
sub.paid_cycle >= order.cycle ||
|
|
551
|
+
(sub.cancel_effective_at && sub.cancel_effective_at <= now)) {
|
|
552
|
+
outcome = "review_required";
|
|
553
|
+
await c.query("UPDATE sr_payment_orders SET state='review_required',revision=$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), order.id, event.revision]);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const plan = PlanInput.parse(order.plan);
|
|
557
|
+
p.customer = sub.customer;
|
|
558
|
+
await this.store.grantTx(c, p, "customer_sale", plan.asset, plan.credits, "subscription:" + sub.id + ":" + order.cycle, order.period_end, plan.service_ids, "subscription");
|
|
559
|
+
const bucket = await c.query("SELECT id FROM sr_buckets WHERE workspace=$1 AND environment=$2 AND source=$3", [...scope(p), "subscription:" + sub.id + ":" + order.cycle]);
|
|
560
|
+
await c.query("INSERT INTO sr_entitlements VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", [
|
|
561
|
+
...scope(p),
|
|
562
|
+
sub.id,
|
|
563
|
+
order.cycle,
|
|
564
|
+
sub.customer,
|
|
565
|
+
plan.service_ids,
|
|
566
|
+
order.period_start,
|
|
567
|
+
order.period_end,
|
|
568
|
+
bucket.rows[0].id,
|
|
569
|
+
]);
|
|
570
|
+
await c.query("UPDATE sr_payment_orders SET state='succeeded',revision=$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), order.id, event.revision]);
|
|
571
|
+
await c.query("UPDATE sr_subscriptions SET status='active',paid_cycle=$4,current_period_end=$5,plan_id=$6,plan_version=$7,next_plan_id=NULL,next_plan_version=NULL WHERE workspace=$1 AND environment=$2 AND id=$3", [
|
|
572
|
+
...scope(p),
|
|
573
|
+
sub.id,
|
|
574
|
+
order.cycle,
|
|
575
|
+
order.period_end,
|
|
576
|
+
plan.id,
|
|
577
|
+
plan.version,
|
|
578
|
+
]);
|
|
579
|
+
outcome = "granted";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
await c.query("INSERT INTO sr_payment_events(integration,event_id,workspace,environment,order_id,digest,outcome) VALUES($1,$2,$3,$4,$5,$6,$7)", [integration.id, event.id, ...scope(p), order.id, digest, outcome]);
|
|
583
|
+
await this.audit(c, p, "payment." + outcome, order.id, {
|
|
584
|
+
event_id: event.id,
|
|
585
|
+
cycle: order.cycle,
|
|
586
|
+
});
|
|
587
|
+
return { accepted: true, outcome };
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/** Reserve dispatch before external I/O. Unknown outcomes never automatically create another checkout. */
|
|
591
|
+
async checkout(p, orderId) {
|
|
592
|
+
const prepared = await this.tx(p, async (c) => {
|
|
593
|
+
const r = await c.query("SELECT * FROM sr_payment_orders WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), orderId]);
|
|
594
|
+
const order = r.rows[0];
|
|
595
|
+
if (!order)
|
|
596
|
+
throw new RouterError("resource_not_found", 404);
|
|
597
|
+
const integration = this.integrations.find((i) => i.id === order.integration &&
|
|
598
|
+
i.workspace === p.workspace &&
|
|
599
|
+
i.environment === p.environment);
|
|
600
|
+
if (!integration || integration.mode !== "hosted")
|
|
601
|
+
throw new RouterError("hosted_payment_required", 422);
|
|
602
|
+
if (order.state !== "pending" || order.period_end <= this.clock())
|
|
603
|
+
throw new RouterError("order_not_payable", 409);
|
|
604
|
+
await c.query("INSERT INTO sr_integration_bindings VALUES($1,$2,$3) ON CONFLICT DO NOTHING", [integration.id, ...scope(p)]);
|
|
605
|
+
const binding = await c.query("SELECT 1 FROM sr_integration_bindings WHERE id=$1 AND workspace=$2 AND environment=$3", [integration.id, ...scope(p)]);
|
|
606
|
+
if (!binding.rowCount)
|
|
607
|
+
throw new RouterError("integration_binding_conflict", 409);
|
|
608
|
+
const old = await c.query("SELECT * FROM sr_checkouts WHERE workspace=$1 AND environment=$2 AND order_id=$3", [...scope(p), orderId]);
|
|
609
|
+
if (old.rows[0]) {
|
|
610
|
+
if (old.rows[0].identity !== integration.adapter.identity)
|
|
611
|
+
throw new RouterError("integration_binding_conflict", 409);
|
|
612
|
+
if (old.rows[0].state !== "ready")
|
|
613
|
+
throw new RouterError("checkout_reconciliation_required", 409);
|
|
614
|
+
return {
|
|
615
|
+
order,
|
|
616
|
+
integration,
|
|
617
|
+
existing: {
|
|
618
|
+
reference: old.rows[0].reference,
|
|
619
|
+
url: old.rows[0].url,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
await c.query("INSERT INTO sr_checkouts(workspace,environment,order_id,integration,identity,state) VALUES($1,$2,$3,$4,$5,'creating')", [...scope(p), orderId, integration.id, integration.adapter.identity]);
|
|
624
|
+
return { order, integration, existing: null };
|
|
625
|
+
}, "billing:write");
|
|
626
|
+
if (prepared.existing)
|
|
627
|
+
return prepared.existing;
|
|
628
|
+
try {
|
|
629
|
+
const result = await prepared.integration.adapter.create(prepared.order);
|
|
630
|
+
await this.tx(p, async (c) => {
|
|
631
|
+
const saved = await c.query("UPDATE sr_checkouts SET state='ready',reference=$4,url=$5 WHERE workspace=$1 AND environment=$2 AND order_id=$3 AND (reference IS NULL OR reference=$4)", [...scope(p), orderId, result.reference, result.url]);
|
|
632
|
+
if (!saved.rowCount)
|
|
633
|
+
throw new RouterError("payment_reference_mismatch", 409);
|
|
634
|
+
await this.audit(c, p, "checkout.created", orderId);
|
|
635
|
+
});
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
await this.tx(p, async (c) => {
|
|
640
|
+
await c.query("UPDATE sr_checkouts SET state='unknown' WHERE workspace=$1 AND environment=$2 AND order_id=$3 AND state='creating'", [...scope(p), orderId]);
|
|
641
|
+
});
|
|
642
|
+
throw new RouterError("checkout_reconciliation_required", 502);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async hostedNotification(integrationId, raw, headers) {
|
|
646
|
+
const i = this.integrations.find((x) => x.id === integrationId);
|
|
647
|
+
if (!i || i.mode !== "hosted")
|
|
648
|
+
throw new RouterError("resource_not_found", 404);
|
|
649
|
+
let reference;
|
|
650
|
+
try {
|
|
651
|
+
reference = i.adapter.notification(raw, headers);
|
|
652
|
+
}
|
|
653
|
+
catch (e) {
|
|
654
|
+
if (e instanceof RouterError)
|
|
655
|
+
throw e;
|
|
656
|
+
throw new RouterError("invalid_notification");
|
|
657
|
+
}
|
|
658
|
+
if (reference)
|
|
659
|
+
await this.confirmHosted(i, reference);
|
|
660
|
+
return i.adapter.acknowledgement;
|
|
661
|
+
}
|
|
662
|
+
async confirmHosted(i, reference) {
|
|
663
|
+
// Restrict authenticated lookup to our persisted checkout references.
|
|
664
|
+
const r = await this.store.pool.query("SELECT order_id,identity FROM sr_checkouts WHERE workspace=$1 AND environment=$2 AND integration=$3 AND reference=$4", [i.workspace, i.environment, i.id, reference]);
|
|
665
|
+
if (!r.rows[0] || r.rows[0].identity !== i.adapter.identity)
|
|
666
|
+
throw new RouterError("checkout_reconciliation_required", 409);
|
|
667
|
+
const receipt = await i.adapter.lookup(reference);
|
|
668
|
+
if (!receipt)
|
|
669
|
+
return { accepted: true, outcome: "pending" };
|
|
670
|
+
if (receipt.order_id !== r.rows[0].order_id)
|
|
671
|
+
throw new RouterError("payment_reference_mismatch", 409);
|
|
672
|
+
return this.settle(i, PaymentInput.parse({
|
|
673
|
+
id: "paid:" + reference,
|
|
674
|
+
order_id: receipt.order_id,
|
|
675
|
+
amount_minor: receipt.amount_minor,
|
|
676
|
+
currency: receipt.currency,
|
|
677
|
+
status: "succeeded",
|
|
678
|
+
revision: 1,
|
|
679
|
+
}), receipt);
|
|
680
|
+
}
|
|
681
|
+
async reconcilePayment(p, orderId, recoveryReference) {
|
|
682
|
+
if (recoveryReference !== undefined &&
|
|
683
|
+
(typeof recoveryReference !== "string" ||
|
|
684
|
+
recoveryReference.length < 1 ||
|
|
685
|
+
recoveryReference.length > 255))
|
|
686
|
+
throw new RouterError("invalid_reference");
|
|
687
|
+
const row = await this.tx(p, async (c) => {
|
|
688
|
+
const r = await c.query("SELECT * FROM sr_checkouts WHERE workspace=$1 AND environment=$2 AND order_id=$3", [...scope(p), orderId]);
|
|
689
|
+
return r.rows[0];
|
|
690
|
+
}, "billing:write");
|
|
691
|
+
if (!row)
|
|
692
|
+
throw new RouterError("resource_not_found", 404);
|
|
693
|
+
const i = this.integrations.find((x) => x.id === row.integration &&
|
|
694
|
+
x.workspace === p.workspace &&
|
|
695
|
+
x.environment === p.environment);
|
|
696
|
+
if (!i || i.mode !== "hosted")
|
|
697
|
+
throw new RouterError("resource_not_found", 404);
|
|
698
|
+
if (!row.reference && recoveryReference) {
|
|
699
|
+
// Recovery only attaches a provider-confirmed, matching paid transaction.
|
|
700
|
+
const receipt = await i.adapter.lookup(recoveryReference);
|
|
701
|
+
if (!receipt ||
|
|
702
|
+
receipt.order_id !== orderId ||
|
|
703
|
+
receipt.reference !== recoveryReference)
|
|
704
|
+
throw new RouterError("payment_reference_mismatch", 409);
|
|
705
|
+
await this.tx(p, async (c) => {
|
|
706
|
+
const r = await c.query("SELECT * FROM sr_payment_orders WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), orderId]);
|
|
707
|
+
if (r.rows[0]?.amount_minor !== receipt.amount_minor ||
|
|
708
|
+
r.rows[0]?.currency !== receipt.currency ||
|
|
709
|
+
row.identity !== i.adapter.identity)
|
|
710
|
+
throw new RouterError("payment_amount_mismatch", 422);
|
|
711
|
+
const updated = await c.query("UPDATE sr_checkouts SET reference=$4 WHERE workspace=$1 AND environment=$2 AND order_id=$3 AND reference IS NULL", [...scope(p), orderId, recoveryReference]);
|
|
712
|
+
if (!updated.rowCount)
|
|
713
|
+
throw new RouterError("checkout_reconciliation_required", 409);
|
|
714
|
+
await this.audit(c, p, "checkout.reference_recovered", orderId);
|
|
715
|
+
}, "billing:write");
|
|
716
|
+
row.reference = recoveryReference;
|
|
717
|
+
}
|
|
718
|
+
if (!row.reference)
|
|
719
|
+
throw new RouterError("checkout_reconciliation_required", 409);
|
|
720
|
+
return this.confirmHosted(i, row.reference);
|
|
721
|
+
}
|
|
722
|
+
async grantCredits(p, raw, key) {
|
|
723
|
+
const input = CreditGrantInput.parse(raw);
|
|
724
|
+
return this.command(p, "billing:write", "credits.grant", key, input, async (c) => {
|
|
725
|
+
await this.requireCustomer(c, p, input.customer_id);
|
|
726
|
+
if (input.service_ids)
|
|
727
|
+
await this.requireServices(c, p, input.service_ids, input.asset);
|
|
728
|
+
if (input.expires_at && new Date(input.expires_at) <= this.clock())
|
|
729
|
+
throw new RouterError("invalid_expiry");
|
|
730
|
+
const target = { ...p, customer: input.customer_id };
|
|
731
|
+
const account = await this.store.grantTx(c, target, "customer_sale", input.asset, input.amount, "manual:" + key, input.expires_at ? new Date(input.expires_at) : undefined, input.service_ids, input.source_kind);
|
|
732
|
+
await this.audit(c, p, "credits.granted", account, {
|
|
733
|
+
customer: input.customer_id,
|
|
734
|
+
amount: input.amount,
|
|
735
|
+
reason: input.reason,
|
|
736
|
+
});
|
|
737
|
+
return { account_id: account, granted: input.amount };
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
async getSubscription(p, id) {
|
|
741
|
+
return this.tx(p, async (c) => {
|
|
742
|
+
const sub = await this.subscription(c, p, id);
|
|
743
|
+
return {
|
|
744
|
+
...sub,
|
|
745
|
+
status: sub.cancel_effective_at && sub.cancel_effective_at <= this.clock()
|
|
746
|
+
? "cancelled"
|
|
747
|
+
: sub.current_period_end && sub.current_period_end <= this.clock()
|
|
748
|
+
? "past_due"
|
|
749
|
+
: sub.status,
|
|
750
|
+
};
|
|
751
|
+
}, "billing:read");
|
|
752
|
+
}
|
|
753
|
+
async customerBalance(p, customer) {
|
|
754
|
+
await this.tx(p, (c) => this.requireCustomer(c, p, customer), "billing:read");
|
|
755
|
+
return this.store.balance({ ...p, customer });
|
|
756
|
+
}
|
|
757
|
+
async buckets(p, customer = p.customer) {
|
|
758
|
+
return this.tx(p, async (c) => {
|
|
759
|
+
if (customer !== p.customer && !p.scopes.includes("billing:read"))
|
|
760
|
+
throw new RouterError("scope_denied", 403);
|
|
761
|
+
const rows = await c.query("SELECT b.id,b.source_kind,b.service_ids,b.expires_at,b.remaining::text,b.held::text,a.asset FROM sr_buckets b JOIN sr_accounts a ON (b.workspace,b.environment,b.account)=(a.workspace,a.environment,a.id) WHERE b.workspace=$1 AND b.environment=$2 AND a.owner=$3 ORDER BY b.expires_at NULLS LAST,b.id", [...scope(p), customer]);
|
|
762
|
+
return rows.rows.map((b) => ({
|
|
763
|
+
...b,
|
|
764
|
+
available: b.expires_at && b.expires_at <= this.clock()
|
|
765
|
+
? "0"
|
|
766
|
+
: (BigInt(b.remaining) - BigInt(b.held)).toString(),
|
|
767
|
+
}));
|
|
768
|
+
}, p.scopes.includes("billing:read") ? "billing:read" : "credits:read");
|
|
769
|
+
}
|
|
770
|
+
async auditLog(p, limit = 50, cursor) {
|
|
771
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
772
|
+
throw new RouterError("invalid_limit");
|
|
773
|
+
return this.tx(p, async (c) => {
|
|
774
|
+
let after = [];
|
|
775
|
+
if (cursor) {
|
|
776
|
+
try {
|
|
777
|
+
after = JSON.parse(Buffer.from(cursor, "base64url").toString());
|
|
778
|
+
if (after.length !== 2 ||
|
|
779
|
+
typeof after[0] !== "string" ||
|
|
780
|
+
typeof after[1] !== "string" ||
|
|
781
|
+
!Number.isFinite(Date.parse(after[0])))
|
|
782
|
+
throw new Error();
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
throw new RouterError("invalid_cursor");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const rows = await c.query(`SELECT id,actor,action,resource,details,created_at,to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at FROM sr_audit WHERE workspace=$1 AND environment=$2 ${cursor ? "AND (created_at,id)>($4::timestamptz,$5)" : ""} ORDER BY created_at,id LIMIT $3`, [...scope(p), limit + 1, ...after]);
|
|
789
|
+
const page = rows.rows.slice(0, limit), last = page.at(-1), data = page.map(({ cursor_at, ...entry }) => entry);
|
|
790
|
+
return {
|
|
791
|
+
data,
|
|
792
|
+
next_cursor: rows.rows.length > limit
|
|
793
|
+
? Buffer.from(JSON.stringify([last.cursor_at, last.id])).toString("base64url")
|
|
794
|
+
: null,
|
|
795
|
+
};
|
|
796
|
+
}, "operations:read");
|
|
797
|
+
}
|
|
798
|
+
async advancePeriods() {
|
|
799
|
+
const r = await this.store.pool.query("SELECT DISTINCT workspace,environment FROM sr_subscriptions WHERE status IN ('active','pending','past_due')");
|
|
800
|
+
for (const t of r.rows) {
|
|
801
|
+
const p = { ...t, customer: "_system", scopes: [] };
|
|
802
|
+
await this.tx(p, async (c) => {
|
|
803
|
+
await c.query("UPDATE sr_subscriptions SET status=CASE WHEN cancel_effective_at<=$3 THEN 'cancelled' ELSE 'past_due' END WHERE workspace=$1 AND environment=$2 AND ((cancel_effective_at<=$3) OR (current_period_end<=$3 AND status='active'))", [...scope(p), this.clock()]);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return r.rowCount;
|
|
807
|
+
}
|
|
808
|
+
}
|