11// Paid automatic rotation (Anthony, 2026-10-29): $29 buys one team 30 days of
22// rotation_auto_approve, so its due runs rotate without the approval click.
33//
4- // CoinPay payments are one-off (crypto or PayPal), so nothing renews by itself:
5- // a checkout buys a fixed term and paying again extends it from the current end.
6- // The webhook and the Rotation page's return check both settle through
7- // settlePayment(), which is idempotent on the checkout session id. The hourly
8- // sweep turns the switch off when the paid days run out and emails the team's
9- // owners and admins a renewal link 3 days before.
4+ // The checkout, webhook settle, return check and hourly expiry/renewal sweep live
5+ // in @profullstack /key-rotation; this file binds it to credshare_teams, the
6+ // team's owners and admins, the audit log and this app's CoinPay business.
107//
118// rotation_auto_paid_until NULL with the switch on means it was flipped by hand
129// (a comp): it never expires and a payment does not cap it.
13- import { CoinPay , readWebhook , verifyWebhook } from "@profullstack/coinpay-onboard" ;
10+ import { CoinPay , readWebhook } from "@profullstack/coinpay-onboard" ;
11+ import { createAutoRotation , sqlStore , resendMailer , DAY_MS } from "@profullstack/key-rotation" ;
1412import { get , all , run } from "../db.mjs" ;
1513import { config } from "../config.mjs" ;
1614
17- export const DAY_MS = 24 * 60 * 60 * 1000 ;
15+ export { DAY_MS , readWebhook } ;
1816export const AUTO_ROTATION_PRICE_USD = Number ( process . env . AUTO_ROTATION_PRICE_USD || 29 ) ;
1917export const AUTO_ROTATION_DAYS = 30 ;
2018export const RENEWAL_NOTICE_DAYS = 3 ;
2119export const PROPERTY = "app.logicsrc.com" ;
2220export const PURPOSE = "auto_rotation" ;
2321
24- export { readWebhook } ;
25-
26- /** Is automatic rotation on for this team right now? */
27- export function autoRotationState ( team , now = Date . now ( ) ) {
28- const flag = Boolean ( Number ( team ?. rotation_auto_approve ) ) ;
29- const paidUntil = team ?. rotation_auto_paid_until == null ? null : Number ( team . rotation_auto_paid_until ) ;
30- const manual = flag && paidUntil === null ;
31- const on = flag && ( manual || paidUntil > now ) ;
32- return { on, manual, paidUntil, expired : flag && ! manual && paidUntil <= now , daysLeft : on && ! manual ? Math . ceil ( ( paidUntil - now ) / DAY_MS ) : null } ;
33- }
34-
35- export const autoRotationOn = ( team , now = Date . now ( ) ) => autoRotationState ( team , now ) . on ;
22+ export const store = sqlStore ( {
23+ get, all, run,
24+ subjects : { table : "credshare_teams" } ,
25+ payments : { table : "rotation_payments" , subject : "team_id" } ,
26+ approverEmails : async ( teamId ) => ( await all (
27+ `SELECT u.email FROM credshare_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = ? AND m.status = 'active' AND m.role IN ('owner', 'admin')` ,
28+ [ teamId ]
29+ ) ) . map ( ( r ) => r . email ) . filter ( Boolean ) ,
30+ audit : ( { key, subject, userId, action, paidUntil, now } ) => run (
31+ `INSERT INTO credshare_audit (id, team_id, actor_user_id, action, key_name, created_at) VALUES (?,?,?,?,?,?)` ,
32+ [ `aud_${ key } ` , subject . id , userId || subject . created_by , action === "extended" ? "rotation.auto.extended" : "rotation.auto.paid" , new Date ( paidUntil ) . toISOString ( ) . slice ( 0 , 10 ) , now ]
33+ ) ,
34+ } ) ;
3635
3736/** A CoinPay client on app.logicsrc.com's business key. */
3837export function coinpayClient ( env = process . env ) {
@@ -41,149 +40,50 @@ export function coinpayClient(env = process.env) {
4140 return new CoinPay ( { token, base : env . COINPAY_API_URL || undefined } ) ;
4241}
4342
44- /** Open a hosted checkout for 30 days of automatic rotation on one team. */
45- export async function createCheckout ( { team, user, client = coinpayClient ( ) , origin = config . origin } ) {
46- if ( ! user ?. id || ! user ?. email ) throw new Error ( "A signed-in user with an email is required" ) ;
47- const back = `${ origin } /teams/${ encodeURIComponent ( team . slug ) } /rotation` ;
48- return client . createCheckoutSession ( {
49- amount : AUTO_ROTATION_PRICE_USD ,
50- currency : "USD" ,
51- description : `LogicSRC automatic rotation for ${ team . slug } , ${ AUTO_ROTATION_DAYS } days` ,
52- customerEmail : user . email ,
53- accountId : user . id ,
43+ /** The module bound to this app; `origin` only changes the links (tests). */
44+ export function autoRotation ( origin = config . origin , send = resendMailer ( config . resend ) ) {
45+ return createAutoRotation ( {
5446 property : PROPERTY ,
55- metadata : { purpose : PURPOSE , team_id : team . id , team_slug : team . slug , user_id : user . id , days : AUTO_ROTATION_DAYS } ,
56- successUrl : `${ back } ?checkout=success&session_id={CHECKOUT_SESSION_ID}` ,
57- cancelUrl : `${ back } ?checkout=cancelled` ,
58- expiresInMinutes : 60 ,
47+ purpose : PURPOSE ,
48+ product : "LogicSRC" ,
49+ priceUsd : AUTO_ROTATION_PRICE_USD ,
50+ days : AUTO_ROTATION_DAYS ,
51+ noticeDays : RENEWAL_NOTICE_DAYS ,
52+ store,
53+ coinpay : ( ) => coinpayClient ( ) ,
54+ webhookSecret : ( ) => process . env . COINPAY_WEBHOOK_SECRET ,
55+ pageUrl : ( team ) => `${ origin } /teams/${ encodeURIComponent ( team . slug ) } /rotation` ,
56+ send,
5957 } ) ;
6058}
59+ const app = autoRotation ( ) ;
6160
62- /** Verify X-CoinPay-Signature over the raw body. */
63- export function verifySignature ( rawBody , header , secret = process . env . COINPAY_WEBHOOK_SECRET ) {
64- if ( ! secret || ! rawBody ) return false ;
65- try { return verifyWebhook ( rawBody , header , secret ) ; } catch { return false ; }
66- }
61+ /** Is automatic rotation on for this team row right now? */
62+ export const autoRotationState = ( team , now = Date . now ( ) ) => app . state ( store . toSubject ( team ) , now ) ;
63+ export const autoRotationOn = ( team , now = Date . now ( ) ) => autoRotationState ( team , now ) . on ;
6764
68- /**
69- * Grant the days a paid checkout bought. Idempotent on the checkout session (or
70- * payment) id: the first settle extends the team, any later one (confirmed then
71- * forwarded, a webhook retry, the return check) changes nothing.
72- */
73- export async function settlePayment ( payment , now = Date . now ( ) ) {
74- const { checkoutSessionId, paymentId, teamId } = payment ;
75- const key = checkoutSessionId || paymentId ;
76- if ( ! key ) return { handled : false , reason : "no checkout session or payment id" } ;
77- if ( ! teamId ) return { handled : false , reason : "no team_id" } ;
78- // No amount check: only this site's business key can open a session, at the
79- // server's price, and a crypto webhook's `amount` may be in the coin, not USD.
80- const team = await get ( `SELECT * FROM credshare_teams WHERE id = ?` , [ teamId ] ) ;
81- if ( ! team ) return { handled : false , reason : "unknown team" } ;
65+ /** Open a hosted checkout for 30 days of automatic rotation on one team. */
66+ export const createCheckout = ( { team, user, client, origin } ) =>
67+ ( origin ? autoRotation ( origin ) : app ) . createCheckout ( { subject : team , user, client } ) ;
8268
83- const ins = await run (
84- `INSERT INTO rotation_payments (checkout_session_id, payment_id, team_id, user_id, amount_usd, method, days, created_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT (checkout_session_id) DO NOTHING` ,
85- [ key , paymentId || null , teamId , payment . userId || null , payment . amountUsd ?? null , payment . method || null , AUTO_ROTATION_DAYS , now ]
86- ) ;
87- if ( ! Number ( ins . rowsAffected ) ) return { handled : true , duplicate : true , teamId } ;
69+ export const verifySignature = ( rawBody , header , secret ) => app . verifySignature ( rawBody , header , secret ) ;
8870
89- const state = autoRotationState ( team , now ) ;
90- if ( state . manual ) {
91- await run ( `UPDATE rotation_payments SET paid_until = NULL WHERE checkout_session_id = ?` , [ key ] ) ;
92- return { handled : true , action : "already-on-by-hand" , teamId } ;
93- }
94- const from = state . on ? state . paidUntil : now ;
95- const paidUntil = from + AUTO_ROTATION_DAYS * DAY_MS ;
96- await run (
97- `UPDATE credshare_teams SET rotation_auto_approve = 1, rotation_auto_paid_until = ?, rotation_auto_reminded_for = NULL WHERE id = ?` ,
98- [ paidUntil , teamId ]
99- ) ;
100- await run ( `UPDATE rotation_payments SET paid_until = ? WHERE checkout_session_id = ?` , [ paidUntil , key ] ) ;
101- await run (
102- `INSERT INTO credshare_audit (id, team_id, actor_user_id, action, key_name, created_at) VALUES (?,?,?,?,?,?)` ,
103- [ `aud_${ key } ` , teamId , payment . userId || team . created_by , state . on ? "rotation.auto.extended" : "rotation.auto.paid" , new Date ( paidUntil ) . toISOString ( ) . slice ( 0 , 10 ) , now ]
104- ) . catch ( ( ) => { } ) ;
105- return { handled : true , action : state . on ? "extended" : "activated" , teamId, paidUntil } ;
71+ /** Grant the days a paid checkout bought; idempotent on the checkout session id. */
72+ export async function settlePayment ( { teamId, ...payment } , now = Date . now ( ) ) {
73+ const { subjectId, ...result } = await app . settlePayment ( { ...payment , subjectId : teamId } , now ) ;
74+ return subjectId === undefined ? result : { ...result , teamId : subjectId } ;
10675}
10776
10877/** Settle from a verified webhook payload. */
10978export async function settleWebhook ( payload , now = Date . now ( ) ) {
110- const event = readWebhook ( payload ) ;
111- if ( ! event . paid ) return { handled : false , type : event . type , status : event . status } ;
112- if ( event . property && event . property !== PROPERTY ) return { handled : false , reason : "other property" } ;
113- const meta = event . metadata || { } ;
114- if ( meta . purpose && meta . purpose !== PURPOSE ) return { handled : false , reason : `purpose ${ meta . purpose } ` } ;
115- return settlePayment ( {
116- checkoutSessionId : event . checkoutSessionId ,
117- paymentId : event . paymentId ,
118- teamId : meta . team_id ,
119- userId : meta . user_id || event . accountId ,
120- amountUsd : event . amountUsd ,
121- method : event . method ,
122- } , now ) ;
123- }
124-
125- /**
126- * The Rotation page's return check: if CoinPay already marks the session paid
127- * for this team, settle it now (the webhook may still be on its way).
128- */
129- export async function reconcileReturn ( sessionId , team , client = coinpayClient ( ) , now = Date . now ( ) ) {
130- if ( ! sessionId || ! / ^ c s _ [ A - Z a - z 0 - 9 _ - ] + $ / . test ( sessionId ) ) return null ;
131- const session = await client . checkoutSession ( sessionId ) ;
132- const meta = session . metadata || { } ;
133- if ( meta . team_id !== team . id || ( meta . purpose && meta . purpose !== PURPOSE ) ) return null ;
134- if ( session . status === "paid" ) {
135- await settlePayment ( {
136- checkoutSessionId : session . id ,
137- paymentId : session . paymentId || null ,
138- teamId : team . id ,
139- userId : meta . user_id || session . accountId ,
140- amountUsd : session . amount ,
141- method : session . paidMethod || null ,
142- } , now ) ;
143- }
144- return session . status ;
145- }
146-
147- async function sendMail ( to , subject , text ) {
148- if ( ! config . resend . apiKey || ! to . length ) return false ;
149- const response = await fetch ( "https://api.resend.com/emails" , {
150- method : "POST" ,
151- headers : { authorization : `Bearer ${ config . resend . apiKey } ` , "content-type" : "application/json" } ,
152- body : JSON . stringify ( { from : config . resend . from , to, subject, text } ) ,
153- } ) . catch ( ( ) => null ) ;
154- return Boolean ( response && response . ok ) ;
79+ const { subjectId, ...result } = await app . settleWebhook ( payload , now ) ;
80+ return subjectId === undefined ? result : { ...result , teamId : subjectId } ;
15581}
15682
157- async function approverEmails ( teamId ) {
158- return ( await all (
159- `SELECT u.email FROM credshare_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = ? AND m.status = 'active' AND m.role IN ('owner', 'admin')` ,
160- [ teamId ]
161- ) ) . map ( ( r ) => r . email ) . filter ( Boolean ) ;
162- }
83+ /** The Rotation page's return check. */
84+ export const reconcileReturn = ( sessionId , team , client , now = Date . now ( ) ) =>
85+ app . reconcileReturn ( sessionId , team , client , now ) ;
16386
164- /**
165- * The hourly sweep: turn the switch off for teams whose paid days ran out, and
166- * email a renewal link to teams with 3 days or less left (once per paid term).
167- */
168- export async function sweepAutoRotation ( { now = Date . now ( ) , send = sendMail , origin = config . origin } = { } ) {
169- const lapsed = await all (
170- `SELECT id, slug FROM credshare_teams WHERE rotation_auto_approve = 1 AND rotation_auto_paid_until IS NOT NULL AND rotation_auto_paid_until <= ?` ,
171- [ now ]
172- ) ;
173- for ( const t of lapsed ) {
174- await run ( `UPDATE credshare_teams SET rotation_auto_approve = 0 WHERE id = ? AND rotation_auto_paid_until <= ?` , [ t . id , now ] ) ;
175- await send ( await approverEmails ( t . id ) , `Automatic rotation is now off for ${ t . slug } ` ,
176- `The paid automatic rotation for the LogicSRC team "${ t . slug } " has ended. Due keys now wait for an owner or admin to approve them again.\n\nTurn it back on ($${ AUTO_ROTATION_PRICE_USD } for ${ AUTO_ROTATION_DAYS } days): ${ origin } /teams/${ encodeURIComponent ( t . slug ) } /rotation` ) ;
177- }
178- const ending = await all (
179- `SELECT id, slug, rotation_auto_paid_until FROM credshare_teams WHERE rotation_auto_approve = 1 AND rotation_auto_paid_until > ? AND rotation_auto_paid_until <= ? AND (rotation_auto_reminded_for IS NULL OR rotation_auto_reminded_for <> rotation_auto_paid_until)` ,
180- [ now , now + RENEWAL_NOTICE_DAYS * DAY_MS ]
181- ) ;
182- for ( const t of ending ) {
183- const until = new Date ( Number ( t . rotation_auto_paid_until ) ) . toISOString ( ) . slice ( 0 , 10 ) ;
184- await send ( await approverEmails ( t . id ) , `Automatic rotation for ${ t . slug } ends ${ until } ` ,
185- `The paid automatic rotation for the LogicSRC team "${ t . slug } " ends on ${ until } (UTC). After that, due keys wait for an owner or admin to approve them.\n\nRenew for another ${ AUTO_ROTATION_DAYS } days ($${ AUTO_ROTATION_PRICE_USD } , added to the current end): ${ origin } /teams/${ encodeURIComponent ( t . slug ) } /rotation` ) ;
186- await run ( `UPDATE credshare_teams SET rotation_auto_reminded_for = ? WHERE id = ?` , [ t . rotation_auto_paid_until , t . id ] ) ;
187- }
188- return { expired : lapsed . map ( ( t ) => t . slug ) , reminded : ending . map ( ( t ) => t . slug ) } ;
189- }
87+ /** The hourly sweep: expiry + one renewal notice per term. */
88+ export const sweepAutoRotation = ( { now = Date . now ( ) , send, origin } = { } ) =>
89+ ( origin ? autoRotation ( origin ) : app ) . sweep ( { now, ...( send ? { send } : { } ) } ) ;
0 commit comments