feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card

Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 14:48:38 +02:00
parent 5697137c52
commit bba988c4e8
11 changed files with 477 additions and 32 deletions
+31 -2
View File
@@ -298,16 +298,45 @@ export interface Subscription {
credentials: SubscriptionCredential[];
plates: string[];
}
export type SubscriptionInput = Omit<Subscription, "id" | "status"> & {
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
* auto-generates an unguessable code. RF must carry the card id. */
export interface SubscriptionCredentialInput {
kind: "rf" | "qr";
value?: string;
}
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
status?: Subscription["status"];
credentials: SubscriptionCredentialInput[];
plates: string[];
};
/** The create response = the saved subscription + the auto-print outcome. */
export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
}
export function createSubscription(body: SubscriptionInput): Promise<Subscription> {
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
}
/** Re-print a subscription's QR card (failed auto-print / lost card). */
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
}
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}