Skip to content
118 changes: 117 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,119 @@
# Customer documents for ISPGrid

Customer documents (ID proofs, agreements) for [ISPGrid](https://github.com/ispgrid/ispgrid). Work in progress.
An add-on for [ISPGrid](https://github.com/ispgrid/ispgrid) that keeps each customer's documents: ID proofs, a
photo, a signed agreement. Staff photograph a card with the phone's camera or upload a PDF while they create the
customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can keep whatever they need. Which
documents complete a customer's KYC differs from customer to customer (Aadhaar for a person, PAN and GSTIN for a
business), so the add-on asks only whether a customer needs documents at all.

> [!WARNING]
> **Preview: not ready for production yet.** Like ISPGrid itself, it can bring breaking changes until v1.0.0. Read the
> release notes before you upgrade.

## What it does

- **Document types you write:** on System, then Add-ons, add the types your ISP keeps (nothing is prefilled): a
name, a help text for staff (for example "Upload a masked Aadhaar"), and whether to keep an ID number: none, the
full number, or only the last 4 characters. One file per type per customer.
- **Document upload is required** (same place): when ticked, a customer cannot be saved, when added or edited,
until at least one document is uploaded. Customers you had before ticking it need one the next time they are
edited. Off by default.
- **Documents tab in the customer form:** the customer's documents, and an upload box: pick the type, type the ID
number when the type keeps one, **Choose file** (on a phone this also offers the camera). **Add another document**
adds a box, so all of a new customer's papers go in with one save. JPG, PNG or PDF, up to 10 MB, checked by the
file's content.
- **Documents tab on the customer page:** the documents, 3 to a row (1 on a phone), with View, Edit (type or
number) and Delete; one upload box; and Download all, one zip of the customer's documents for an audit. ID
numbers are shown masked (`xxxx xxxx 4821`); **Show** reveals the full number.
- **Nothing is lost by accident:** Delete hides a document; **Deleted documents** (a button in this add-on's
settings) restores it or erases it for good. Uploading a type the customer already has replaces the file (it asks
first on the customer page) and hides the old one the same way. Documents stay when a customer is deleted.
- **Everything is on record:** every view, Show, download, upload, change, delete, restore, erase and export is
written to the Activity log (never the number or the file).
- **Permissions** on the Roles screen, none given to a role on install (Admin has them all):
"View customer documents", "Upload documents", "Edit and delete documents", "Restore deleted documents", "Erase
deleted documents" and "Document types". Ticking one ticks what it needs (Erase ticks Restore, which ticks View).
- English and Hindi.

Not yet: shrinking photos on the phone before upload.

## Aadhaar and ID numbers

- **Full number or last 4:** the choice is yours, per type. A full number is stored encrypted and shown masked.
UIDAI's Aadhaar Data Vault rule (a separate vault for Aadhaar numbers) applies to bodies that check Aadhaar online
with UIDAI ("requesting entities", the ones doing Aadhaar e-KYC), as UIDAI's 2025 circular is summarised by law
firms; an ISP that keeps a photo of the card and types the number is not one. If your ISP becomes one, switch the
type to "Last 4 characters only": the panel then cuts every stored number of that type to its last 4 (it asks
first; this cannot be undone). Check the current rules with your adviser.
- **Masked Aadhaar:** where you can, ask customers for a masked Aadhaar (first 8 digits hidden) and say so in the
type's help text.
- **Duplicates:** a full number already on another customer gives a warning (compared on letters and digits,
ignoring case), never a block.

## Encryption, backups and erasing

- Files and full ID numbers are encrypted with the panel's `APP_KEY` (in `/opt/ispgrid/.env`). Without that key they
cannot be read. **Never change `APP_KEY` by hand.** For a planned change, keep the old key in `APP_PREVIOUS_KEYS`.
- The nightly ISPGrid backup holds the documents, the database and `.env` with the key, so restoring a backup (also
on a new server) brings everything back readable. A stolen backup holds the key too: keep backups safe.
- **Erase for good** removes the file at once, but backups taken before still hold it until they age out (14 days).
- While staff pick a file, Livewire keeps it unencrypted in its temporary folder; the add-on deletes it as soon as
the document is saved. A file picked but never saved stays there until Livewire's cleanup (about a day).

## Taking documents out

- **One customer, for an audit:** Download all on the customer's Documents tab (one zip, logged).
- **Everything, for leaving ISPGrid:** on the server,

```bash
cd /opt/ispgrid && sudo -u ispgrid php artisan documents:export /tmp/documents-export
```

writes readable copies, one folder per customer, and `documents.csv` (customer, type, ID number as stored, who
uploaded it and when). `--customer=<username>` exports one customer, `--include-deleted` adds hidden documents.
The folder must be new or empty, and the files in it are readable: keep it safe and delete it when done.
- **Moving to another ISPGrid server:** no export needed. Restore the backup; it carries the key.

## Install

It needs ISPGrid 0.1.2 or newer (on an older ISPGrid it only adds its permissions and keeps its data). On a server
set up with the ISPGrid installer, as root:

```bash
sudo /opt/ispgrid/deploy/install.sh --addon=ispgrid/documents
```

The installer keeps the add-on on every upgrade, and the nightly backup keeps its database `ispgrid_documents` and
its files (`storage/app/private/addons/documents`).

## Remove

```bash
sudo /opt/ispgrid/deploy/install.sh --remove-addon=ispgrid/documents
```

The tabs and pages go away, but the documents are kept: installing the add-on again shows them as they were. To
delete every document too:

```bash
sudo /opt/ispgrid/deploy/install.sh --remove-addon=ispgrid/documents --delete-data
```

The backup taken at the start of that run keeps a copy for 14 days; after that the documents cannot be brought back.

## Development

In an ISPGrid checkout:

```bash
composer config repositories.ispgrid-documents '{"type": "path", "url": "../path/to/ispgrid-documents", "options": {"symlink": true}}'
composer require ispgrid/documents:@dev
ISPGRID_ADDONS=on php vendor/bin/pest --test-directory=vendor/ispgrid/documents/tests vendor/ispgrid/documents/tests
```

Do not commit the changed `composer.json` and `composer.lock` in the ISPGrid checkout. The tests use the database
`ispgrid_documents_test` next to ISPGrid's `radius_test`.

## License

MIT
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public function up(): void
$table->id();
$table->string('name', 60)->unique();
$table->string('help', 200)->nullable();
$table->boolean('required')->default(false);
$table->string('number_mode', 5)->default('none');
$table->unsignedInteger('position')->default(0);
$table->boolean('active')->default(true);
Expand Down
74 changes: 74 additions & 0 deletions lang/hi.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,85 @@
{
":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।",
":date by :name": ":date, :name द्वारा",
":type (replaces the current file)": ":type (मौजूदा फ़ाइल की जगह लेगा)",
":type :number is already on :customer.": ":type :number पहले से :customer के पास है।",
":type saved.": ":type सेव हो गया।",
"A customer cannot be saved, when added or edited, until at least one document is uploaded.": "जब तक कम से कम एक दस्तावेज़ अपलोड न हो, ग्राहक को जोड़ते या बदलते समय सेव नहीं किया जा सकता।",
"Add another document": "एक और दस्तावेज़ जोड़ें",
"Add document type": "दस्तावेज़ का प्रकार जोड़ें",
"Add the file for this number.": "इस नंबर के लिए फ़ाइल जोड़ें।",
"Backups taken before an erase still hold the file for up to 14 days.": "मिटाने से पहले लिए गए बैकअप में फ़ाइल 14 दिन तक रहती है।",
"Choose a type": "प्रकार चुनें",
"Choose each document type once.": "हर दस्तावेज़ प्रकार एक ही बार चुनें।",
"Choose file": "फ़ाइल चुनें",
"Choose the document type.": "दस्तावेज़ का प्रकार चुनें।",
"Choose the file.": "फ़ाइल चुनें।",
"Clear the number": "नंबर हटाएँ",
"Cut the numbers and save": "नंबर छोटे करें और सेव करें",
"Delete the :type of :customer?": ":customer का :type हटाएँ?",
"Deleted": "हटाया गया",
"Deleted documents": "हटाए गए दस्तावेज़",
"Deleted documents are hidden from the customer, not removed. Restore one, or erase it for good.": "हटाए गए दस्तावेज़ ग्राहक से छिपाए जाते हैं, मिटाए नहीं जाते। किसी को वापस लाएँ, या हमेशा के लिए मिटाएँ।",
"Document changed": "दस्तावेज़ में बदलाव",
"Document deleted": "दस्तावेज़ हटाया गया",
"Document deleted. It can be restored from Deleted documents.": "दस्तावेज़ हटा दिया गया। इसे हटाए गए दस्तावेज़ों से वापस लाया जा सकता है।",
"Document erased": "दस्तावेज़ मिटाया गया",
"Document erased.": "दस्तावेज़ मिटा दिया गया।",
"Document replaced": "दस्तावेज़ बदला गया",
"Document restored": "दस्तावेज़ वापस लाया गया",
"Document restored.": "दस्तावेज़ वापस आ गया।",
"Document saved.": "दस्तावेज़ सेव हो गया।",
"Document type": "दस्तावेज़ का प्रकार",
"Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।",
"Document types": "दस्तावेज़ के प्रकार",
"Document upload is required": "दस्तावेज़ अपलोड करना ज़रूरी है",
"Document uploaded": "दस्तावेज़ अपलोड हुआ",
"Document viewed": "दस्तावेज़ देखा गया",
"Documents": "दस्तावेज़",
"Documents downloaded": "दस्तावेज़ डाउनलोड हुए",
"Documents exported": "दस्तावेज़ निर्यात हुए",
"Download all": "सब डाउनलोड करें",
"Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ",
"Edit document": "दस्तावेज़ बदलें",
"Edit document type": "दस्तावेज़ का प्रकार बदलें",
"Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ",
"Erase for good": "हमेशा के लिए मिटाएँ",
"Erase the :type of :customer for good? This cannot be undone.": ":customer का :type हमेशा के लिए मिटाएँ? यह वापस नहीं हो सकता।",
"Full ID number": "पूरा आईडी नंबर",
"Help text": "मदद का टेक्स्ट",
"ID number": "आईडी नंबर",
"ID number shown": "आईडी नंबर दिखाया गया",
"ID numbers cut to the last 4": "आईडी नंबर आखिरी 4 तक छोटे किए गए",
"JPG, PNG or PDF, up to 10 MB.": "JPG, PNG या PDF, 10 MB तक।",
"Last 4 characters only": "केवल आखिरी 4 अक्षर",
"Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।",
"Move down": "नीचे ले जाएँ",
"Move up": "ऊपर ले जाएँ",
"Name or username": "नाम या यूज़रनेम",
"No ID number": "कोई आईडी नंबर नहीं",
"No deleted documents.": "कोई हटाया गया दस्तावेज़ नहीं।",
"No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।",
"No documents yet.": "अभी कोई दस्तावेज़ नहीं।",
"Off": "बंद",
"Replace the :type of :customer? The current file can be restored from Deleted documents.": ":customer का :type बदलें? मौजूदा फ़ाइल हटाए गए दस्तावेज़ों से वापस लाई जा सकती है।",
"Restore": "वापस लाएँ",
"Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ",
"Saved.": "सेव हो गया।",
"Show": "दिखाएँ",
"Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।",
"Switch off": "बंद करें",
"Switch on": "चालू करें",
"That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।",
"The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.": "हर ग्राहक के लिए आपका स्टाफ़ जो दस्तावेज़ रखता है। जिस प्रकार के दस्तावेज़ हैं उसे बंद किया जा सकता है, हटाया नहीं।",
"These documents are too large for one download. Use documents:export on the server.": "ये दस्तावेज़ एक बार में डाउनलोड करने के लिए बहुत बड़े हैं। सर्वर पर documents:export चलाएँ।",
"This type has documents. Switch it off instead.": "इस प्रकार के दस्तावेज़ हैं। इसकी जगह इसे बंद करें।",
"Type the number on one line, up to :max characters.": "नंबर एक ही लाइन में लिखें, ज़्यादा से ज़्यादा :max अक्षर।",
"Unknown": "अज्ञात",
"Upload": "अपलोड करें",
"Upload a JPG, PNG or PDF.": "JPG, PNG या PDF अपलोड करें।",
"Upload at least one document.": "कम से कम एक दस्तावेज़ अपलोड करें।",
"Upload documents": "दस्तावेज़ अपलोड करें",
"Uploaded :date by :name": ":date को :name ने अपलोड किया",
"View": "देखें",
"View customer documents": "ग्राहक दस्तावेज़ देखें"
}
60 changes: 60 additions & 0 deletions resources/views/customer-tab.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{{-- The Documents tab, included by core's customer form. It shows only what the tab's state() prepared. --}}
@php($tab = $addonTabs['documents'] ?? [])

@error('addonTabs.documents.uploads')
<x-ui.alert variant="danger">{{ $message }}</x-ui.alert>
@enderror

@if (($tab['documents'] ?? []) !== [])
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tab['documents'] as $document)
<div wire:key="documents-current-{{ $loop->index }}" class="rounded-lg border border-zinc-200 p-3 text-sm dark:border-zinc-700">
<div class="font-medium">{{ $document['type'] }}</div>
<x-ui.text muted class="break-all">{{ $document['file'] }}</x-ui.text>
<x-ui.text muted>
{{ __('Uploaded :date by :name', ['date' => $document['date'], 'name' => $document['by']]) }}
@if ($document['number'] !== '')
&middot; <span class="font-mono">{{ $document['number'] }}</span>
@endif
</x-ui.text>
</div>
@endforeach
</div>
@endif

@if (($tab['typeOptions'] ?? []) === [])
<x-ui.text muted>{{ __('No document types yet. Add them on System, then Add-ons.') }}</x-ui.text>
@else
@foreach ($tab['uploads'] ?? [] as $index => $row)
@continue(! is_array($row))
<div wire:key="documents-upload-{{ $index }}" class="grid gap-3 rounded-lg border border-zinc-200 p-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-zinc-700">
<x-ui.select
wire:model.live="addonTabs.documents.uploads.{{ $index }}.type"
:label="__('Document type')"
:options="$tab['typeOptions']"
:placeholder="__('Choose a type')"
:description="$tab['types'][$row['type'] ?? '']['help'] ?? null"
/>
@if ($tab['types'][$row['type'] ?? '']['numbered'] ?? false)
<x-ui.input wire:model="addonTabs.documents.uploads.{{ $index }}.number" :label="__('ID number')" maxlength="40" autocomplete="off" />
@endif
<x-ui.file-upload
wire:model="addonTabs.documents.uploads.{{ $index }}.file"
:label="__('Choose file')"
accept="image/jpeg,image/png,application/pdf"
:description="__('JPG, PNG or PDF, up to 10 MB.')"
/>
@if ($index > 0)
<div class="sm:col-span-2 lg:col-span-3">
<x-ui.button size="sm" variant="ghost" wire:click="$set('addonTabs.documents.uploads.{{ $index }}', null)">{{ __('Remove') }}</x-ui.button>
</div>
@endif
</div>
@endforeach

@if (count($tab['uploads'] ?? []) < \Ispgrid\Documents\DocumentsTab::MAX_ROWS)
<div>
<x-ui.button size="sm" variant="secondary" icon="plus" wire:click="$set('addonTabs.documents.uploads.{{ count($tab['uploads'] ?? []) }}', { type: '', number: '', file: null })">{{ __('Add another document') }}</x-ui.button>
</div>
@endif
@endif
Loading