gigafibre-fsm/apps/ops/src/composables/useSubscriptionActions.js
louispaulb 0a0f1b2d8a feat: subscription reimport, customer/doctype ID rename, zero-padded format
- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
  SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
  EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 17:17:23 -04:00

202 lines
7.8 KiB
JavaScript

/**
* Composable for subscription management actions.
* Handles status toggling, frequency changes, recurring settings, drag reorder, and field saves.
*/
import { ref } from 'vue'
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { formatMoney } from 'src/composables/useFormatters'
// Frappe REST update — supports both Service Subscription (legacy) and native Subscription
async function updateSub (name, fields) {
// Detect which doctype based on name pattern: SUB-* = Service Subscription, ASUB-* = native Subscription
const doctype = name.startsWith('ASUB-') ? 'Subscription' : 'Service Subscription'
// Map normalized field names back to target doctype fields
const mapped = {}
for (const [k, v] of Object.entries(fields)) {
if (doctype === 'Service Subscription') {
// Map template field names → Service Subscription field names
if (k === 'actual_price') mapped.monthly_price = v
else if (k === 'custom_description') mapped.plan_name = v
else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel'
else if (k === 'status') mapped.status = v === 'Active' ? 'Actif' : v === 'Cancelled' ? 'Annulé' : v
else if (k === 'cancelation_date') mapped.cancellation_date = v
else if (k === 'cancel_at_period_end') { /* not applicable to Service Subscription */ }
else mapped[k] = v
} else {
mapped[k] = v
}
}
const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mapped),
})
if (!res.ok) {
const err = await res.text()
throw new Error('Update failed: ' + err)
}
return res.json()
}
/**
* @param {import('vue').Ref<Array>} subscriptions - Reactive subscriptions list
* @param {import('vue').Ref<Object>} customer - Reactive customer object
* @param {import('vue').Ref<Array>} comments - Reactive comments list
* @param {Function} invalidateCache - Cache invalidation from useSubscriptionGroups
*/
export function useSubscriptionActions (subscriptions, customer, comments, invalidateCache) {
const subSaving = ref(null)
const togglingRecurring = ref(null)
// Log a subscription change as a Comment on the Customer for audit trail
async function logSubChange (sub, message) {
try {
const label = sub.custom_description || sub.item_name || sub.item_code || sub.name
const body = {
doctype: 'Comment', comment_type: 'Comment',
reference_doctype: 'Customer', reference_name: customer.value.name,
content: `[${sub.name}] ${label}${message}`,
}
const res = await authFetch(BASE_URL + '/api/resource/Comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
comments.value.unshift(data.data)
}
} catch {}
}
async function toggleSubStatus (sub) {
const action = sub.name + ':status'
if (subSaving.value) return
subSaving.value = action
const newStatus = sub.status === 'Cancelled' ? 'Active' : 'Cancelled'
const today = new Date().toISOString().slice(0, 10)
try {
const updates = { status: newStatus }
if (newStatus === 'Cancelled') {
updates.cancelation_date = today
if (!sub.end_date) updates.end_date = sub.current_invoice_end || today
} else {
updates.cancelation_date = null
}
await updateSub(sub.name, updates)
sub.status = newStatus
if (updates.end_date) sub.end_date = updates.end_date
const msg = newStatus === 'Cancelled'
? `Service désactivé le ${today}`
: `Service réactivé le ${today}`
logSubChange(sub, msg)
if (sub.service_location) invalidateCache(sub.service_location)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier le statut'), timeout: 4000 })
} finally {
subSaving.value = null
}
}
async function toggleFrequency (sub) {
const action = sub.name + ':freq'
if (subSaving.value) return
subSaving.value = action
const oldFreq = sub.billing_frequency
const newFreq = oldFreq === 'A' ? 'M' : 'A'
try {
await updateSub(sub.name, { billing_frequency: newFreq })
const msg = newFreq === 'A'
? `Fréquence changée: Mensuel → Annuel`
: `Fréquence changée: Annuel → Mensuel`
logSubChange(sub, msg)
if (sub.service_location) invalidateCache(sub.service_location)
sub.billing_frequency = newFreq
} catch {} finally {
subSaving.value = null
}
}
async function toggleRecurring (sub) {
if (togglingRecurring.value) return
togglingRecurring.value = sub.name
const newVal = Number(sub.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
if (newVal && !sub.end_date) updates.end_date = sub.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(sub.name, updates)
sub.cancel_at_period_end = newVal
if (updates.end_date) sub.end_date = updates.end_date
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(sub, msg)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch {
Notify.create({ type: 'negative', message: 'Erreur: impossible de modifier le récurrent', timeout: 3000 })
} finally {
togglingRecurring.value = null
}
}
async function toggleRecurringModal (doc) {
const newVal = Number(doc.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
if (newVal && !doc.end_date) updates.end_date = doc.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(doc.name, updates)
doc.cancel_at_period_end = newVal
if (updates.end_date) doc.end_date = updates.end_date
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub.cancel_at_period_end = newVal
if (updates.end_date) listSub.end_date = updates.end_date
}
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(doc, msg)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier'), timeout: 3000 })
}
}
async function saveSubField (doc, field) {
try {
const oldVal = subscriptions.value.find(s => s.name === doc.name)?.[field]
await updateSub(doc.name, { [field]: doc[field] ?? '' })
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub[field] = doc[field]
if (listSub.service_location) invalidateCache(listSub.service_location)
}
const fieldLabels = { actual_price: 'Prix', custom_description: 'Description' }
if (fieldLabels[field]) {
const newVal = doc[field] ?? ''
const msg = field === 'actual_price'
? `${fieldLabels[field]} modifié: ${formatMoney(oldVal)}${formatMoney(newVal)}`
: `${fieldLabels[field]} modifié: "${oldVal || '—'}" → "${newVal || '—'}"`
logSubChange(doc, msg)
}
} catch {}
}
function onSubDragChange (evt, locName) {
if (evt.added || evt.removed) invalidateCache(locName)
}
return {
subSaving,
togglingRecurring,
toggleSubStatus,
toggleFrequency,
toggleRecurring,
toggleRecurringModal,
saveSubField,
logSubChange,
onSubDragChange,
}
}