mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 12:06:42 +02:00
feat(sync): Phase 2 — server-side billing gate, cron charging, email notifications
Server-side gating (mana-sync Go):
- New billing.Checker with 5-minute cache per user
- Middleware wraps POST/GET /sync/{appId} endpoints
- Returns 402 Payment Required when sync subscription inactive
- Fail-open: if mana-credits is unreachable, sync is allowed
- Config: MANA_CREDITS_URL + MANA_SERVICE_KEY env vars
Recurring charge cron (mana-credits):
- Hourly setInterval checks for due sync subscriptions
- Calls chargeRecurring() which debits credits and advances nextChargeAt
- On insufficient credits: pauses subscription, sends email via mana-notify
Email notifications:
- Sends "Cloud Sync pausiert" email via mana-notify when subscription paused
- Uses POST /api/v1/notifications/send with X-Service-Key auth
Client-side 402 handling:
- sync.ts detects 402 from push/pull, fires onBillingRequired callback
- Layout wires callback to reload syncBilling store → shows pause banner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7102063afc
commit
ed76f53b00
7 changed files with 209 additions and 13 deletions
|
|
@ -539,6 +539,7 @@ export function createUnifiedSync(
|
|||
let status: SyncStatus = 'idle';
|
||||
let online = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
||||
let _statusListeners: Array<(s: SyncStatus) => void> = [];
|
||||
let _billingRequiredCallback: (() => void) | null = null;
|
||||
const sseAbortControllers = new Map<string, AbortController>();
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────
|
||||
|
|
@ -714,6 +715,10 @@ export function createUnifiedSync(
|
|||
`push[${appId}]`
|
||||
);
|
||||
|
||||
if (res.status === 402) {
|
||||
_billingRequiredCallback?.();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Push failed: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
|
@ -813,6 +818,10 @@ export function createUnifiedSync(
|
|||
`pull[${appId}/${syncName}]`
|
||||
);
|
||||
|
||||
if (res.status === 402) {
|
||||
_billingRequiredCallback?.();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) break;
|
||||
|
||||
const data = await res.json();
|
||||
|
|
@ -1089,6 +1098,9 @@ export function createUnifiedSync(
|
|||
_statusListeners = _statusListeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
onBillingRequired(callback: () => void) {
|
||||
_billingRequiredCallback = callback;
|
||||
},
|
||||
getChannel: (appId: string) => channels.get(appId),
|
||||
pushNow: push,
|
||||
pullNow: pull,
|
||||
|
|
|
|||
|
|
@ -471,6 +471,10 @@
|
|||
// Update pending count when sync status changes
|
||||
await refreshPendingCount();
|
||||
});
|
||||
unifiedSync.onBillingRequired(() => {
|
||||
// Server returned 402 — sync subscription expired or paused
|
||||
syncBilling.load();
|
||||
});
|
||||
unifiedSync.startAll();
|
||||
// Seed the badge count on mount: onStatusChange only fires on
|
||||
// transitions, so without this the badge stays at its last known
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue