- 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>
202 lines
7.8 KiB
JavaScript
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,
|
|
}
|
|
}
|