nixamp 0.67.1 → 0.67.4
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/dist/ai-label.d.ts +12 -0
- package/dist/ai-label.js +34 -5
- package/dist/license-routes.js +6 -13
- package/dist/music-license.js +4 -3
- package/dist/music-store.js +4 -3
- package/dist/server.js +76 -15
- package/dist/store-routes.js +6 -14
- package/dist/support.d.ts +77 -8
- package/dist/support.js +229 -93
- package/dist/veo-studio.d.ts +6 -0
- package/dist/veo-studio.js +68 -36
- package/package.json +2 -2
- package/src/ai-label.ts +40 -6
- package/src/license-routes.ts +6 -13
- package/src/music-license.ts +4 -3
- package/src/music-store.ts +4 -3
- package/src/server.ts +59 -9
- package/src/store-routes.ts +6 -14
- package/src/support.ts +227 -90
- package/src/veo-studio.ts +62 -30
- package/web/dist/assets/{ar-DPe7Pp8y.js → ar-CRV9Quan.js} +1 -1
- package/web/dist/assets/{cs-CJcjEsDV.js → cs-CAoAOSEV.js} +1 -1
- package/web/dist/assets/{da-Bd1GXRyz.js → da-DUvxzaUE.js} +1 -1
- package/web/dist/assets/{de-CLZfazCs.js → de-BdA0QfUh.js} +1 -1
- package/web/dist/assets/{es-BLqEfqLZ.js → es-DfbHSteU.js} +1 -1
- package/web/dist/assets/{fi-anv-MFsC.js → fi-65IkDcD0.js} +1 -1
- package/web/dist/assets/{fr-BzD2HDGv.js → fr-ZCLeigWh.js} +1 -1
- package/web/dist/assets/{hi-D71Abrr0.js → hi-CgUjpeV3.js} +1 -1
- package/web/dist/assets/{hu-BmLNPlTb.js → hu-Nk3lmNzK.js} +1 -1
- package/web/dist/assets/{id-D804zCDF.js → id-CSbL1VEB.js} +1 -1
- package/web/dist/assets/{it-BAuY8mMB.js → it-Ca1dxbJu.js} +1 -1
- package/web/dist/assets/main-3VBJDnTo.js +22 -0
- package/web/dist/assets/{nl-D8x7GCIF.js → nl-BGzV-Nib.js} +1 -1
- package/web/dist/assets/{ru-Ci9fQdcv.js → ru--JBLHgkK.js} +1 -1
- package/web/dist/assets/{sv-CZtgl4E6.js → sv-xxdDl1G_.js} +1 -1
- package/web/dist/assets/{uk-VhX0Jvpy.js → uk-BK5Gpfe7.js} +1 -1
- package/web/dist/assets/{vi-TdUCiab8.js → vi-COp3eRc5.js} +1 -1
- package/web/dist/assets/{zh-BMJXXIBE.js → zh-xl2IR6MI.js} +1 -1
- package/web/dist/index.html +2 -3
- package/web/dist/sw.js +19 -19
- package/web/dist/assets/main-DbQrVbPR.js +0 -22
package/dist/ai-label.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface LabelSong {
|
|
|
25
25
|
videoTagged?: boolean;
|
|
26
26
|
/** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
|
|
27
27
|
videoVersion?: number;
|
|
28
|
+
/**
|
|
29
|
+
* The singer in its filmed video: "synced" (close-ups lip-synced), "unsynced" (filmed, but no
|
|
30
|
+
* sync could be made, so the band plays there). Absent on a filmed video means the singer was
|
|
31
|
+
* never filmed, and with lip sync on it is filmed again.
|
|
32
|
+
*/
|
|
33
|
+
singer?: "synced" | "unsynced";
|
|
28
34
|
/** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
|
|
29
35
|
made: string;
|
|
30
36
|
composedAt: number;
|
|
@@ -213,6 +219,10 @@ export declare function closeUpTimes(seconds: number, lines: Array<{
|
|
|
213
219
|
* the mic in the wide shots).
|
|
214
220
|
*/
|
|
215
221
|
export declare const FILMED = 6;
|
|
222
|
+
/** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
|
|
223
|
+
export declare const VEO_QUOTA: RegExp;
|
|
224
|
+
/** The next midnight in California, when Google's daily quotas reset. */
|
|
225
|
+
export declare function nextPacificMidnight(now: number): number;
|
|
216
226
|
/**
|
|
217
227
|
* A single's music video, cut from its filmed shots alone: 4 s cuts, each
|
|
218
228
|
* shot's first half then its second, round and round the shots until the song
|
|
@@ -348,6 +358,8 @@ export declare class AiLabel {
|
|
|
348
358
|
private alignTried;
|
|
349
359
|
/** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
|
|
350
360
|
private veoSpentIn;
|
|
361
|
+
/** Until when Google's daily Veo quota is used up (its midnight, Pacific): nothing is asked before. */
|
|
362
|
+
private veoQuotaUntil;
|
|
351
363
|
/** Old videos already tried as storyboards since this server started. */
|
|
352
364
|
private upgradeTried;
|
|
353
365
|
/** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
|
package/dist/ai-label.js
CHANGED
|
@@ -678,6 +678,16 @@ export function closeUpTimes(seconds, lines, count, length = 8) {
|
|
|
678
678
|
* the mic in the wide shots).
|
|
679
679
|
*/
|
|
680
680
|
export const FILMED = 6;
|
|
681
|
+
/** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
|
|
682
|
+
export const VEO_QUOTA = /exceeded your current quota|RESOURCE_EXHAUSTED/i;
|
|
683
|
+
/** The next midnight in California, when Google's daily quotas reset. */
|
|
684
|
+
export function nextPacificMidnight(now) {
|
|
685
|
+
const hour = (at) => Number(new Intl.DateTimeFormat("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hourCycle: "h23" }).format(new Date(at)));
|
|
686
|
+
let at = Math.floor(now / 3_600_000) * 3_600_000 + 3_600_000;
|
|
687
|
+
for (let i = 0; i < 26 && hour(at) !== 0; i++)
|
|
688
|
+
at += 3_600_000;
|
|
689
|
+
return at;
|
|
690
|
+
}
|
|
681
691
|
/**
|
|
682
692
|
* A single's music video, cut from its filmed shots alone: 4 s cuts, each
|
|
683
693
|
* shot's first half then its second, round and round the shots until the song
|
|
@@ -814,6 +824,8 @@ export class AiLabel {
|
|
|
814
824
|
alignTried = new Set();
|
|
815
825
|
/** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
|
|
816
826
|
veoSpentIn = "";
|
|
827
|
+
/** Until when Google's daily Veo quota is used up (its midnight, Pacific): nothing is asked before. */
|
|
828
|
+
veoQuotaUntil = 0;
|
|
817
829
|
/** Old videos already tried as storyboards since this server started. */
|
|
818
830
|
upgradeTried = new Set();
|
|
819
831
|
/** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
|
|
@@ -1169,7 +1181,9 @@ export class AiLabel {
|
|
|
1169
1181
|
.filter((album) => album.cover || album.coverTried)
|
|
1170
1182
|
.flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
|
|
1171
1183
|
// Only a single is filmed, and only with Veo footage: a song with an older (slideshow) video is filmed when its turn comes.
|
|
1172
|
-
|
|
1184
|
+
// A filmed video whose singer was never filmed (a quota or a refusal took the close-ups) is filmed again while it stays up.
|
|
1185
|
+
const singerless = (song) => Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0 && (song.videoVersion ?? 1) >= FILMED && !song.singer;
|
|
1186
|
+
const missing = songs.filter(({ song }) => this.wantsVeo(song) && ((song.videoVersion ?? 1) < FILMED || singerless(song)) && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
|
|
1173
1187
|
const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
|
|
1174
1188
|
const old = [];
|
|
1175
1189
|
let made = 0;
|
|
@@ -1205,7 +1219,10 @@ export class AiLabel {
|
|
|
1205
1219
|
const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
|
|
1206
1220
|
const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
|
|
1207
1221
|
try {
|
|
1208
|
-
|
|
1222
|
+
const filmed = await this.filmSong(artist, album, song, scenes, partial, upgrade);
|
|
1223
|
+
song.videoVersion = filmed.version;
|
|
1224
|
+
if (filmed.singer)
|
|
1225
|
+
song.singer = filmed.singer;
|
|
1209
1226
|
renameSync(partial, out);
|
|
1210
1227
|
song.video = out;
|
|
1211
1228
|
song.videoTagged = true;
|
|
@@ -1243,10 +1260,14 @@ export class AiLabel {
|
|
|
1243
1260
|
const wanted = this.settings.veoShots + this.settings.storyShots;
|
|
1244
1261
|
if (clips.length < Math.max(2, Math.ceil(wanted / 2)))
|
|
1245
1262
|
throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
|
|
1263
|
+
// The singer is the point of the video: with lip sync on, nothing is cut until a close-up is filmed.
|
|
1264
|
+
const singing = Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0;
|
|
1265
|
+
if (singing && singer.length === 0)
|
|
1266
|
+
throw new Error("the singer's close-ups are not filmed yet");
|
|
1246
1267
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1247
1268
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1248
1269
|
rmSync(shots, { recursive: true, force: true });
|
|
1249
|
-
return FILMED;
|
|
1270
|
+
return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" : "unsynced" } : {}) };
|
|
1250
1271
|
}
|
|
1251
1272
|
/**
|
|
1252
1273
|
* The singer's close-ups, each placed on a sung line and its mouth redrawn
|
|
@@ -1313,7 +1334,8 @@ export class AiLabel {
|
|
|
1313
1334
|
}
|
|
1314
1335
|
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
1315
1336
|
wantsVeo(song) {
|
|
1316
|
-
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7)
|
|
1337
|
+
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7)
|
|
1338
|
+
&& this.now() >= this.veoQuotaUntil;
|
|
1317
1339
|
}
|
|
1318
1340
|
/**
|
|
1319
1341
|
* A single's filmed shots: the band, from their portraits (up to three as
|
|
@@ -1365,10 +1387,11 @@ export class AiLabel {
|
|
|
1365
1387
|
}
|
|
1366
1388
|
// The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
|
|
1367
1389
|
// the portraits: they would pull the whole band into every scene.
|
|
1390
|
+
// The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
|
|
1368
1391
|
const plan = [
|
|
1392
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1369
1393
|
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
|
|
1370
1394
|
...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1371
|
-
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1372
1395
|
];
|
|
1373
1396
|
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1374
1397
|
try {
|
|
@@ -1405,6 +1428,12 @@ export class AiLabel {
|
|
|
1405
1428
|
this.log(`Veo: ${error.message}; no more videos until next month`);
|
|
1406
1429
|
break;
|
|
1407
1430
|
}
|
|
1431
|
+
if (VEO_QUOTA.test(error.message)) {
|
|
1432
|
+
// Google's daily request quota: every further ask today is refused, so none is made.
|
|
1433
|
+
this.veoQuotaUntil = nextPacificMidnight(this.now());
|
|
1434
|
+
this.log(`Veo: Google's daily quota is used up; filming resumes after ${new Date(this.veoQuotaUntil).toISOString()}`);
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1408
1437
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
|
|
1409
1438
|
}
|
|
1410
1439
|
}
|
package/dist/license-routes.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms } from "./music-license.js";
|
|
15
|
-
import { SupportError } from "./support.js";
|
|
15
|
+
import { SupportError, paidIn, payUrl } from "./support.js";
|
|
16
16
|
const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
17
17
|
const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
18
18
|
const CERTIFICATE = /^\/licenses\/([0-9a-f-]{36})$/;
|
|
@@ -58,14 +58,9 @@ const SCRIPT = `
|
|
|
58
58
|
if (!form) return;
|
|
59
59
|
const status = $("#status");
|
|
60
60
|
const pay = $("#pay");
|
|
61
|
-
const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price
|
|
61
|
+
const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price : "Pick a licence"; };
|
|
62
62
|
form.addEventListener("change", label);
|
|
63
63
|
label();
|
|
64
|
-
fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
|
|
65
|
-
const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
|
|
66
|
-
const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
|
|
67
|
-
$("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
|
|
68
|
-
}).catch(() => { status.textContent = "Payments are not available right now."; });
|
|
69
64
|
form.addEventListener("submit", async (event) => {
|
|
70
65
|
event.preventDefault();
|
|
71
66
|
pay.disabled = true;
|
|
@@ -74,7 +69,7 @@ const SCRIPT = `
|
|
|
74
69
|
try {
|
|
75
70
|
const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
76
71
|
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
|
|
77
|
-
projectUrl: data.get("projectUrl"), email: data.get("email"),
|
|
72
|
+
projectUrl: data.get("projectUrl"), email: data.get("email"), agree: data.get("agree") === "on" }) });
|
|
78
73
|
const body = await r.json();
|
|
79
74
|
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
80
75
|
status.textContent = "Taking you to CoinPay…";
|
|
@@ -161,13 +156,11 @@ export async function handleLicenseRoutes(request, response, path, url, ctx) {
|
|
|
161
156
|
<input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
|
|
162
157
|
<label class="f" for="email">Email (your certificate goes here)</label>
|
|
163
158
|
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
164
|
-
<label class="f" for="coin">Pay with</label>
|
|
165
|
-
<select id="coin" name="coin" required></select>
|
|
166
159
|
<label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
|
|
167
160
|
<button id="pay" type="submit">Pay</button>
|
|
168
161
|
</form>
|
|
169
162
|
<p id="status" role="status" aria-live="polite"></p>
|
|
170
|
-
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/<code>.</small>
|
|
163
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/<code>.</small>
|
|
171
164
|
</div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
|
|
172
165
|
return true;
|
|
173
166
|
}
|
|
@@ -193,9 +186,9 @@ export async function handleLicenseRoutes(request, response, path, url, ctx) {
|
|
|
193
186
|
}
|
|
194
187
|
const body = license.status === "paid"
|
|
195
188
|
? certificate(license, ctx, true)
|
|
196
|
-
: `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}
|
|
189
|
+
: `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}${escape(paidIn(license.coin))}</p>
|
|
197
190
|
<p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
|
|
198
|
-
<a class="go" href="
|
|
191
|
+
<a class="go" href="${escape(payUrl(license.paymentId))}">Open the payment</a></div>`;
|
|
199
192
|
html(response, 200, page(`Licence ${license.code}`, body), method);
|
|
200
193
|
return true;
|
|
201
194
|
}
|
package/dist/music-license.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* under. Paid through nixamp.com's CoinPay business like the store.
|
|
17
17
|
*/
|
|
18
18
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
19
|
-
import { SupportError } from "./support.js";
|
|
19
|
+
import { SupportError, coinOf } from "./support.js";
|
|
20
20
|
/** In cents, by tier and what is licensed; a missing pair is not sold. */
|
|
21
21
|
export const LICENSE_PRICES = {
|
|
22
22
|
creator: { song: 500 },
|
|
@@ -132,7 +132,8 @@ export class MusicLicenses {
|
|
|
132
132
|
const code = newCode();
|
|
133
133
|
const checkout = await this.options.support.pay({
|
|
134
134
|
amountUsd: quote.amountCents / 100,
|
|
135
|
-
|
|
135
|
+
email,
|
|
136
|
+
accountId: account?.id || email,
|
|
136
137
|
order: `license-${id}`,
|
|
137
138
|
description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
|
|
138
139
|
metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
|
|
@@ -141,7 +142,7 @@ export class MusicLicenses {
|
|
|
141
142
|
await this.options.db.query(`INSERT INTO music_licenses (id, code, token, email, user_id, licensee, project, project_url, tier, kind, server, artist, album, n,
|
|
142
143
|
title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
|
|
143
144
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`, [id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
|
|
144
|
-
quote.title, quote.artistName, quote.creditUrl, quote.amountCents,
|
|
145
|
+
quote.title, quote.artistName, quote.creditUrl, quote.amountCents, coinOf(input["coin"]), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
|
|
145
146
|
return { url: checkout.url, id, token, code };
|
|
146
147
|
}
|
|
147
148
|
/** The licensee's own page: the certificate once paid. */
|
package/dist/music-store.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* is good before it plays or zips anything (/api/v1/store/grant).
|
|
17
17
|
*/
|
|
18
18
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
19
|
-
import { SupportError } from "./support.js";
|
|
19
|
+
import { SupportError, coinOf } from "./support.js";
|
|
20
20
|
/** In cents. */
|
|
21
21
|
export const PRICES = { song: 10, album: 100, artist: 200 };
|
|
22
22
|
const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
|
|
@@ -188,14 +188,15 @@ export class MusicStore {
|
|
|
188
188
|
const token = randomUUID().replace(/-/g, "");
|
|
189
189
|
const checkout = await this.options.support.pay({
|
|
190
190
|
amountUsd: item.amountCents / 100,
|
|
191
|
-
|
|
191
|
+
email,
|
|
192
|
+
accountId: account?.id || email,
|
|
192
193
|
order: `music-${id}`,
|
|
193
194
|
description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
|
|
194
195
|
metadata: { product: "music", purchase: id, kind: item.kind },
|
|
195
196
|
redirect: this.link(id, token),
|
|
196
197
|
});
|
|
197
198
|
await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
|
|
198
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents,
|
|
199
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, coinOf(input["coin"]), checkout.paymentId, new Date(this.now())]);
|
|
199
200
|
return { url: checkout.url, id, token };
|
|
200
201
|
}
|
|
201
202
|
/** The purchase's own page, which is also the receipt link. */
|
package/dist/server.js
CHANGED
|
@@ -2183,15 +2183,20 @@ export function createHandler(engine, options) {
|
|
|
2183
2183
|
json(response, 400, { error: "invalid JSON" });
|
|
2184
2184
|
return;
|
|
2185
2185
|
}
|
|
2186
|
+
// A hosted checkout session's payment names its session; the purchase
|
|
2187
|
+
// row holds the session id (cs_...), an older one the payment id.
|
|
2188
|
+
const refs = [event.sessionId, event.paymentId].filter(Boolean);
|
|
2186
2189
|
if (options.musicStore) {
|
|
2187
2190
|
try {
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2191
|
+
for (const ref of refs) {
|
|
2192
|
+
if (await options.musicStore.owns(ref)) {
|
|
2193
|
+
json(response, 200, await options.musicStore.settle(ref));
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
if (options.musicLicenses && await options.musicLicenses.owns(ref)) {
|
|
2197
|
+
json(response, 200, await options.musicLicenses.settle(ref));
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2195
2200
|
}
|
|
2196
2201
|
}
|
|
2197
2202
|
catch (error) {
|
|
@@ -2205,7 +2210,7 @@ export function createHandler(engine, options) {
|
|
|
2205
2210
|
return;
|
|
2206
2211
|
}
|
|
2207
2212
|
try {
|
|
2208
|
-
json(response, 200, await options.translationPasses.settle(event
|
|
2213
|
+
json(response, 200, await options.translationPasses.settle(event));
|
|
2209
2214
|
}
|
|
2210
2215
|
catch (error) {
|
|
2211
2216
|
// A 5xx makes CoinPay deliver it again, which is what an unreachable
|
|
@@ -2344,7 +2349,7 @@ export function createHandler(engine, options) {
|
|
|
2344
2349
|
// llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
|
|
2345
2350
|
!(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
|
|
2346
2351
|
// Supporting a copyright holder is for anybody listening.
|
|
2347
|
-
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2352
|
+
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/support\/pay\/[0-9a-f-]{36}\/[0-9a-f]{32}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2348
2353
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2349
2354
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2350
2355
|
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
@@ -2439,6 +2444,52 @@ export function createHandler(engine, options) {
|
|
|
2439
2444
|
response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
|
|
2440
2445
|
return;
|
|
2441
2446
|
}
|
|
2447
|
+
/*
|
|
2448
|
+
* A monthly support's "pay this month" link, from its emails: a fresh
|
|
2449
|
+
* CoinPay checkout session each time it is followed (one still open is
|
|
2450
|
+
* reused), so a late payer is never sent to an expired session.
|
|
2451
|
+
*/
|
|
2452
|
+
const supportPay = /^\/support\/pay\/([0-9a-f-]{36})\/([0-9a-f]{32})$/.exec(path);
|
|
2453
|
+
if (supportPay && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
|
|
2454
|
+
const page = (status, words, again) => {
|
|
2455
|
+
const safe = (text) => text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
2456
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer", "x-robots-tag": "noindex" });
|
|
2457
|
+
response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe(words)}</p>${again ? `<p><a style="color:#3ddc84" href="${safe(again)}">${safe(again.includes("/support/pay/") ? "Try again" : "Start again")}</a></p>` : ""}<p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
|
|
2458
|
+
};
|
|
2459
|
+
// A HEAD (a mail scanner checking the link) never opens a checkout.
|
|
2460
|
+
if (request.method === "HEAD") {
|
|
2461
|
+
page(200, "");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const verdict = guard.check(`support-pay:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
|
|
2465
|
+
if (!verdict.ok) {
|
|
2466
|
+
page(429, "Too many at once; wait a minute and open the link again.");
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
try {
|
|
2470
|
+
const opened = await options.supportSubscriptions.open(supportPay[1], supportPay[2]);
|
|
2471
|
+
if (opened.state === "redirect") {
|
|
2472
|
+
response.writeHead(303, { location: opened.url, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
2473
|
+
response.end();
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const name = options.support?.holder(opened.holder)?.name ?? opened.holder;
|
|
2477
|
+
const site = options.site ?? "https://nixamp.com";
|
|
2478
|
+
if (opened.state === "paid")
|
|
2479
|
+
page(200, `This month's support for ${name} is paid. Thank you. Next month's link comes by email.`);
|
|
2480
|
+
else if (opened.state === "canceled")
|
|
2481
|
+
page(200, `Your monthly support for ${name} was cancelled, so there is nothing to pay.`, `${site}/support/${opened.holder}`);
|
|
2482
|
+
else
|
|
2483
|
+
page(200, `Your monthly support for ${name} stopped after a month went unpaid. Nothing is owed.`, `${site}/support/${opened.holder}`);
|
|
2484
|
+
}
|
|
2485
|
+
catch (error) {
|
|
2486
|
+
if (error instanceof SupportError)
|
|
2487
|
+
page(error.status, error.message, error.status >= 500 ? `${url.pathname}` : undefined);
|
|
2488
|
+
else
|
|
2489
|
+
page(502, "The payment service did not answer; nothing was charged. Try the link again in a minute.", url.pathname);
|
|
2490
|
+
}
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2442
2493
|
/* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
|
|
2443
2494
|
const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
|
|
2444
2495
|
if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -2477,13 +2528,20 @@ export function createHandler(engine, options) {
|
|
|
2477
2528
|
catch {
|
|
2478
2529
|
body = {};
|
|
2479
2530
|
}
|
|
2531
|
+
// CoinPay files the payment under the payer: a signed-in member by
|
|
2532
|
+
// account id (their email if the form left it empty), a guest by email.
|
|
2533
|
+
const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
|
|
2534
|
+
const payer = {
|
|
2535
|
+
email: typeof body["email"] === "string" && body["email"].trim() ? body["email"] : me?.email ?? "",
|
|
2536
|
+
accountId: me?.id ?? null,
|
|
2537
|
+
};
|
|
2480
2538
|
if (body["monthly"] === true) {
|
|
2481
2539
|
if (!options.supportSubscriptions)
|
|
2482
2540
|
throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
|
|
2483
|
-
json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
|
|
2541
|
+
json(response, 200, await options.supportSubscriptions.start({ ...body, ...payer, holder: holder.id }));
|
|
2484
2542
|
}
|
|
2485
2543
|
else {
|
|
2486
|
-
json(response, 200, await support.checkout({ ...body, holder: holder.id }));
|
|
2544
|
+
json(response, 200, await support.checkout({ ...body, ...payer, holder: holder.id }));
|
|
2487
2545
|
}
|
|
2488
2546
|
return;
|
|
2489
2547
|
}
|
|
@@ -3630,9 +3688,12 @@ export function createHandler(engine, options) {
|
|
|
3630
3688
|
catch {
|
|
3631
3689
|
throw new SpeechError("Choose a translation pass.", 400);
|
|
3632
3690
|
}
|
|
3633
|
-
if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["
|
|
3634
|
-
throw new SpeechError("Choose a translation pass
|
|
3635
|
-
|
|
3691
|
+
if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["requestKey"] !== "string")
|
|
3692
|
+
throw new SpeechError("Choose a translation pass.", 400);
|
|
3693
|
+
// A CoinPay hosted session (any coin or PayPal) filed under this
|
|
3694
|
+
// account and its email; `coin` only comes from an older open page.
|
|
3695
|
+
const email = who.email || (typeof body["email"] === "string" ? body["email"] : "");
|
|
3696
|
+
json(response, 200, await service.checkout(who.id, body["plan"], typeof body["coin"] === "string" ? body["coin"] : "", body["requestKey"], { email, accountId: who.id }));
|
|
3636
3697
|
return;
|
|
3637
3698
|
}
|
|
3638
3699
|
const order = path.match(/^\/api\/v1\/translation-passes\/orders\/([^/]+)$/);
|
|
@@ -7550,7 +7611,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7550
7611
|
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
|
|
7551
7612
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
7552
7613
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
7553
|
-
// Monthly support: each month
|
|
7614
|
+
// Monthly support: each month its stable pay link by email (a fresh CoinPay session per click).
|
|
7554
7615
|
const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
|
|
7555
7616
|
db: pool,
|
|
7556
7617
|
support: supportPayments,
|
package/dist/store-routes.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { PRICES } from "./music-store.js";
|
|
14
14
|
import { serverId } from "./music-hub.js";
|
|
15
|
-
import { SupportError } from "./support.js";
|
|
15
|
+
import { SupportError, paidIn, payUrl } from "./support.js";
|
|
16
16
|
const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
|
|
17
17
|
const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
|
|
18
18
|
export function isStorePath(path) {
|
|
@@ -45,12 +45,6 @@ const BUY_SCRIPT = `
|
|
|
45
45
|
(async () => {
|
|
46
46
|
const $ = (s) => document.querySelector(s);
|
|
47
47
|
const status = $("#status");
|
|
48
|
-
try {
|
|
49
|
-
const coins = await (await fetch("/api/v1/store/coins")).json();
|
|
50
|
-
const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
|
|
51
|
-
const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
|
|
52
|
-
$("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
|
|
53
|
-
} catch { status.textContent = "Payments are not available right now."; }
|
|
54
48
|
$("#buy").addEventListener("submit", async (event) => {
|
|
55
49
|
event.preventDefault();
|
|
56
50
|
const button = $("#pay");
|
|
@@ -59,7 +53,7 @@ const BUY_SCRIPT = `
|
|
|
59
53
|
const form = new FormData(event.currentTarget);
|
|
60
54
|
try {
|
|
61
55
|
const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
62
|
-
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email")
|
|
56
|
+
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email") }) });
|
|
63
57
|
const body = await r.json();
|
|
64
58
|
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
65
59
|
status.textContent = "Taking you to CoinPay…";
|
|
@@ -109,12 +103,10 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
|
|
|
109
103
|
<form id="buy">
|
|
110
104
|
<label for="email">Email (your download link goes here)</label>
|
|
111
105
|
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
112
|
-
<
|
|
113
|
-
<select id="coin" name="coin" required></select>
|
|
114
|
-
<button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
|
|
106
|
+
<button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
|
|
115
107
|
</form>
|
|
116
108
|
<p id="status" role="status" aria-live="polite"></p>
|
|
117
|
-
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. For small amounts
|
|
109
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. For small amounts a coin with low fees (USDC on Polygon or Solana, SOL) or PayPal costs least. Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
|
|
118
110
|
</div>`, JSON.stringify({ server: serverId(item.server), kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
|
|
119
111
|
return true;
|
|
120
112
|
}
|
|
@@ -129,9 +121,9 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
|
|
|
129
121
|
? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
|
|
130
122
|
<a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
|
|
131
123
|
<small>MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>, to play or download.</small></div>`
|
|
132
|
-
: `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}
|
|
124
|
+
: `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
|
|
133
125
|
<p>Waiting for your payment. Once CoinPay confirms it, your download is here and in your email (${escape(purchase.email)}). This page checks again when you reload it.</p>
|
|
134
|
-
<a class="go" href="
|
|
126
|
+
<a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
|
|
135
127
|
html(response, 200, page(purchase.title, body), method);
|
|
136
128
|
return true;
|
|
137
129
|
}
|
package/dist/support.d.ts
CHANGED
|
@@ -4,6 +4,21 @@ export interface SupportHolder {
|
|
|
4
4
|
}
|
|
5
5
|
export declare function supportHoldersFrom(env?: Record<string, string | undefined>): SupportHolder[];
|
|
6
6
|
export declare const AMOUNTS: number[];
|
|
7
|
+
/** A CoinPay hosted checkout session id (cs_...), as stored where a payment id used to be. */
|
|
8
|
+
export declare function isSessionRef(ref: string): boolean;
|
|
9
|
+
/** Where a payer finishes a stored payment: its hosted session, or an older one-coin invoice. */
|
|
10
|
+
export declare function payUrl(ref: string): string;
|
|
11
|
+
/** The site hostname every nixamp sale is filed under at CoinPay. */
|
|
12
|
+
export declare function propertyOf(site: string): string;
|
|
13
|
+
/** The coin a stored payment names: "ANY" for a hosted session, where the payer picks (or PayPal). */
|
|
14
|
+
export declare function coinOf(value: unknown): string;
|
|
15
|
+
/** "in USDC_POL" for an older one-coin payment; nothing for a session. */
|
|
16
|
+
export declare function paidIn(coin: string): string;
|
|
17
|
+
/** Who pays: CoinPay needs an email for the receipt; the account id defaults to it (a guest). */
|
|
18
|
+
export interface Payer {
|
|
19
|
+
email?: unknown;
|
|
20
|
+
accountId?: string | null;
|
|
21
|
+
}
|
|
7
22
|
export declare class SupportError extends Error {
|
|
8
23
|
readonly status: number;
|
|
9
24
|
readonly code: string;
|
|
@@ -30,20 +45,49 @@ export declare class Support {
|
|
|
30
45
|
business: string;
|
|
31
46
|
coins: string[];
|
|
32
47
|
}>;
|
|
33
|
-
/**
|
|
48
|
+
/** The POST /api/checkout/sessions body: what CoinPay files the sale under. */
|
|
49
|
+
sessionBody(input: {
|
|
50
|
+
amountUsd: number;
|
|
51
|
+
description: string;
|
|
52
|
+
metadata: Record<string, unknown>;
|
|
53
|
+
redirect: string;
|
|
54
|
+
cancel?: string;
|
|
55
|
+
email: string;
|
|
56
|
+
accountId: string;
|
|
57
|
+
}): Record<string, unknown>;
|
|
58
|
+
/** Open a CoinPay hosted checkout session; its id stands where a payment id used to. */
|
|
59
|
+
private session;
|
|
60
|
+
/**
|
|
61
|
+
* Any CoinPay checkout on nixamp's business (a purchase in the store): no
|
|
62
|
+
* holder, no floor but a cent. A hosted session, so the payer picks any coin
|
|
63
|
+
* or PayPal there; `coin` is what an older page still sends, and is ignored.
|
|
64
|
+
*/
|
|
34
65
|
pay(input: {
|
|
35
66
|
amountUsd: number;
|
|
36
|
-
coin
|
|
67
|
+
coin?: unknown;
|
|
37
68
|
description: string;
|
|
38
69
|
metadata: Record<string, unknown>;
|
|
39
70
|
redirect: string;
|
|
40
71
|
order: string;
|
|
41
|
-
}): Promise<{
|
|
72
|
+
} & Payer): Promise<{
|
|
42
73
|
url: string;
|
|
43
74
|
paymentId: string;
|
|
44
75
|
}>;
|
|
45
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Whether CoinPay counts this payment as paid; null when it did not answer.
|
|
78
|
+
* A session (cs_...) is asked as a session: paid by any coin or PayPal.
|
|
79
|
+
*/
|
|
46
80
|
isPaid(paymentId: string): Promise<boolean | null>;
|
|
81
|
+
/**
|
|
82
|
+
* A hosted session's state: "paid", "open" (with when it expires) or
|
|
83
|
+
* anything else CoinPay says (expired, canceled); null when it did not
|
|
84
|
+
* answer. An older one-coin payment is "paid" or "gone": its page cannot be
|
|
85
|
+
* reopened, so it is never handed out again.
|
|
86
|
+
*/
|
|
87
|
+
sessionState(ref: string): Promise<{
|
|
88
|
+
status: string;
|
|
89
|
+
expiresAt: number;
|
|
90
|
+
} | null>;
|
|
47
91
|
/** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
|
|
48
92
|
checkout(input: {
|
|
49
93
|
holder?: unknown;
|
|
@@ -52,7 +96,7 @@ export declare class Support {
|
|
|
52
96
|
station?: unknown;
|
|
53
97
|
track?: unknown;
|
|
54
98
|
metadata?: Record<string, unknown>;
|
|
55
|
-
}): Promise<{
|
|
99
|
+
} & Payer): Promise<{
|
|
56
100
|
url: string;
|
|
57
101
|
paymentId: string;
|
|
58
102
|
}>;
|
|
@@ -71,9 +115,10 @@ export declare function nextMonth(at: number): number;
|
|
|
71
115
|
* Monthly support, run here rather than by CoinPay: CoinPay's only merchant
|
|
72
116
|
* subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
|
|
73
117
|
* A subscription is an email, an amount, a coin and a holder. Month one is
|
|
74
|
-
* paid at once on CoinPay's checkout; each month after,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
118
|
+
* paid at once on CoinPay's checkout; each month after, the subscription's
|
|
119
|
+
* stable pay link is emailed (it opens a fresh CoinPay session whenever it is
|
|
120
|
+
* followed, since a session expires within two hours), with a one-click
|
|
121
|
+
* cancel. A month left unpaid ends it (lapsed) rather than emailing forever.
|
|
77
122
|
*/
|
|
78
123
|
export declare class SupportSubscriptions {
|
|
79
124
|
private readonly options;
|
|
@@ -102,11 +147,35 @@ export declare class SupportSubscriptions {
|
|
|
102
147
|
email?: unknown;
|
|
103
148
|
station?: unknown;
|
|
104
149
|
track?: unknown;
|
|
150
|
+
accountId?: string | null;
|
|
105
151
|
}): Promise<{
|
|
106
152
|
url: string;
|
|
107
153
|
id: string;
|
|
108
154
|
}>;
|
|
109
155
|
cancelLink(id: string, token: string): string;
|
|
156
|
+
/**
|
|
157
|
+
* The subscription's stable "pay this month" link. A CoinPay session lives
|
|
158
|
+
* at most two hours, so an emailed session link was dead by the time most
|
|
159
|
+
* people read it; this one opens a fresh session whenever it is followed.
|
|
160
|
+
* The token is the subscription's own secret (the cancel link's).
|
|
161
|
+
*/
|
|
162
|
+
payLink(id: string, token: string): string;
|
|
163
|
+
/** The checkouts opened for the subscription's current month, newest last. */
|
|
164
|
+
private static cycleIds;
|
|
165
|
+
/** Whether any of this month's checkouts is paid; null when CoinPay could not say and none is known paid. */
|
|
166
|
+
private cyclePaid;
|
|
167
|
+
/**
|
|
168
|
+
* Following the pay link: this month's checkout to send the payer to.
|
|
169
|
+
* "paid" when a checkout this month is already paid; "redirect" to a
|
|
170
|
+
* session still open for at least 15 minutes, else to a new one.
|
|
171
|
+
*/
|
|
172
|
+
open(id: string, token: string): Promise<{
|
|
173
|
+
state: "redirect";
|
|
174
|
+
url: string;
|
|
175
|
+
} | {
|
|
176
|
+
state: "paid" | "canceled" | "lapsed";
|
|
177
|
+
holder: string;
|
|
178
|
+
}>;
|
|
110
179
|
cancel(id: string, token: string): Promise<{
|
|
111
180
|
canceled: boolean;
|
|
112
181
|
holder: string;
|