Skip to content

Commit a8cd4ec

Browse files
authored
fix(billing): support invoice delete source handling (#4554)
1 parent fb6e001 commit a8cd4ec

27 files changed

Lines changed: 575 additions & 70 deletions

File tree

.agents/skills/billing/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ DeleteInProgress → DeleteSyncing → Deleted (TriggerFailed → DeleteFailed)
174174

175175
**Retrying stuck invoices**: Use the existing `RetryInvoice` service method (`service/invoice.go`) rather than firing `TriggerRetry` directly. `RetryInvoice` first **downgrades all critical validation issues to warnings** before firing the trigger — without this step, `noCriticalValidationErrors` would immediately block re-advancement out of `DraftValidating` and the invoice would land back in `DraftSyncFailed`. For bulk retries, query with `ExtendedStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusDraftSyncFailed}` and call `RetryInvoice` per result.
176176

177+
**Delete lifecycle semantics**: deleting an invoice is modeled as a delete action, not a generic retry. `DeleteInvoice` fires `TriggerDelete` with a typed delete trigger input that carries the delete source. `DeleteInProgress` consumes that input in an `OnEntry` action, stamps the source onto the invoice for audit, and performs source-specific cleanup. If delete syncing fails and the invoice reaches `DeleteFailed`, calling `DeleteInvoice` again should fire `TriggerDelete` again; do not route delete failures through `RetryInvoice`.
178+
177179
## Line Engine Lifecycle
178180

179181
The billing line-engine contract lives in `openmeter/billing/lineengine.go`. Billing owns the orchestration and grouping; each engine owns only the behavior for the lines assigned to its discriminator.

.agents/skills/charges/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Important rules:
153153
- usage-based payment handling is intentionally different from flat-fee and credit-purchase: the usage-based state machine owns realization only, while the usage-based line engine/run service records payment authorization/settlement directly on historical runs and only re-enters the state machine through an aggregate trigger (for example `all_payments_settled`) once all invoiced runs on the charge are settled. Do not apply this rule generically to flat-fee or credit-purchase; those charge types may still keep payment states inside their own state machines.
154154
- usage-based invoice branches should read in this order: `started -> waiting_for_collection -> processing -> issuing -> completed`, then auto-advance out of the branch. Keep `invoice_issued` as the boundary between `processing` and `issuing`, run `FinalizeInvoiceRun(...)` from the `issuing` state, and let `completed` be the last branch-local status before `next` returns a partial invoice to `active` or moves a final invoice to `active.awaiting_payment_settlement`
155155
- when adding or renaming usage-based detailed statuses, remember that `status_detailed` is an Ent enum for `ChargeUsageBased`; run `make generate` so the generated enum validators and migrate schema include the new values before trusting state-machine changes
156+
- when charge code deletes an empty standard invoice, call billing `DeleteInvoice` with `billing.ChangeSourceSystem`; billing stores that source for audit and dispatches `OnMutableStandardLinesDeletedBySystem(...)` only for non-deleted standard lines so charge engines can clean up line-backed runs without mutating the deleted invoice's visible line history
156157

157158
Flat-fee credit-then-invoice lifecycle rules:
158159

openmeter/billing/adapter/invoice.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ func (a *adapter) UpdateStandardInvoice(ctx context.Context, in billing.UpdateSt
482482
SetOrClearDraftUntil(convert.SafeToUTC(in.DraftUntil)).
483483
SetOrClearIssuedAt(convert.SafeToUTC(in.IssuedAt)).
484484
SetOrClearDeletedAt(convert.SafeToUTC(in.DeletedAt)).
485+
SetOrClearDeletionSource(lo.EmptyableToPtr(in.DeletionSource)).
485486
SetOrClearSentToCustomerAt(convert.SafeToUTC(in.SentToCustomerAt)).
486487
SetOrClearQuantitySnapshotedAt(convert.SafeToUTC(in.QuantitySnapshotedAt))
487488

@@ -688,11 +689,12 @@ func (a *adapter) mapStandardInvoiceBaseFromDB(invoice *db.BillingInvoice) billi
688689
},
689690
UsageAttribution: mapCustomerUsageAttributionFromDB(invoice.CustomerID, invoice.CustomerKey, invoice.CustomerUsageAttribution),
690691
},
691-
Period: mapPeriodFromDB(invoice.PeriodStart, invoice.PeriodEnd),
692-
IssuedAt: convert.TimePtrIn(invoice.IssuedAt, time.UTC),
693-
CreatedAt: invoice.CreatedAt.In(time.UTC),
694-
UpdatedAt: invoice.UpdatedAt.In(time.UTC),
695-
DeletedAt: convert.TimePtrIn(invoice.DeletedAt, time.UTC),
692+
Period: mapPeriodFromDB(invoice.PeriodStart, invoice.PeriodEnd),
693+
IssuedAt: convert.TimePtrIn(invoice.IssuedAt, time.UTC),
694+
CreatedAt: invoice.CreatedAt.In(time.UTC),
695+
UpdatedAt: invoice.UpdatedAt.In(time.UTC),
696+
DeletedAt: convert.TimePtrIn(invoice.DeletedAt, time.UTC),
697+
DeletionSource: lo.FromPtr(invoice.DeletionSource),
696698

697699
CollectionAt: normalizeOptionalTime(invoice.CollectionAt),
698700
PaymentProcessingEnteredAt: convert.TimePtrIn(invoice.PaymentProcessingEnteredAt, time.UTC),

openmeter/billing/charges/invoiceupdater/invoiceupdate.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,10 @@ func (u *Updater) updateMutableStandardInvoice(ctx context.Context, invoice bill
456456
return nil
457457
}
458458

459-
invoice, err := u.billingService.DeleteInvoice(ctx, updatedInvoice.GetInvoiceID())
459+
invoice, err := u.billingService.DeleteInvoice(ctx, billing.DeleteInvoiceInput{
460+
Invoice: updatedInvoice.GetInvoiceID(),
461+
DeletionSource: billing.ChangeSourceSystem,
462+
})
460463
if err != nil {
461464
return fmt.Errorf("deleting empty invoice: %w", err)
462465
}

openmeter/billing/httpdriver/invoice.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,12 @@ func (h *handler) DeleteInvoice() DeleteInvoiceHandler {
354354
return DeleteInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
355355
}
356356

357-
return billing.InvoiceID{
358-
ID: params.InvoiceID,
359-
Namespace: ns,
357+
return billing.DeleteInvoiceInput{
358+
Invoice: billing.InvoiceID{
359+
ID: params.InvoiceID,
360+
Namespace: ns,
361+
},
362+
DeletionSource: billing.ChangeSourceAPIRequest,
360363
}, nil
361364
},
362365
func(ctx context.Context, request DeleteInvoiceRequest) (DeleteInvoiceResponse, error) {

openmeter/billing/service/invoice.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ type executeTriggerOnInvoiceOptions struct {
595595
editCallback func(sm *InvoiceStateMachine) error
596596
allowInStates []billing.StandardInvoiceStatus
597597
includeDeletedLines bool
598+
triggerArgs []any
598599
}
599600

600601
func ExecuteTriggerWithEditCallback(cb editCallbackFunc) executeTriggerApplyOptionFunc {
@@ -615,6 +616,12 @@ func ExecuteTriggerWithIncludeDeletedLines(includeDeletedLines bool) executeTrig
615616
}
616617
}
617618

619+
func ExecuteTriggerWithArgs(args ...any) executeTriggerApplyOptionFunc {
620+
return func(opts *executeTriggerOnInvoiceOptions) {
621+
opts.triggerArgs = args
622+
}
623+
}
624+
618625
func (s *Service) executeTriggerOnInvoice(ctx context.Context, invoiceID billing.InvoiceID, trigger billing.InvoiceTrigger, opts ...executeTriggerApplyOptionFunc) (billing.StandardInvoice, error) {
619626
if err := invoiceID.Validate(); err != nil {
620627
return billing.StandardInvoice{}, billing.ValidationError{
@@ -632,7 +639,7 @@ func (s *Service) executeTriggerOnInvoice(ctx context.Context, invoiceID billing
632639
InvoiceID: invoiceID,
633640
IncludeDeletedLines: options.includeDeletedLines,
634641
Callback: func(ctx context.Context, sm *InvoiceStateMachine) error {
635-
canFire, err := sm.CanFire(ctx, trigger)
642+
canFire, err := sm.CanFire(ctx, trigger, options.triggerArgs...)
636643
if err != nil {
637644
return fmt.Errorf("checking if can fire: %w", err)
638645
}
@@ -670,7 +677,7 @@ func (s *Service) executeTriggerOnInvoice(ctx context.Context, invoiceID billing
670677
}
671678
}
672679

673-
if err := sm.FireAndActivate(ctx, trigger); err != nil {
680+
if err := sm.FireAndActivate(ctx, trigger, options.triggerArgs...); err != nil {
674681
validationIssues, err := billing.ToValidationIssues(err)
675682
sm.Invoice.ValidationIssues = validationIssues
676683

@@ -714,18 +721,25 @@ func (s *Service) DeleteInvoice(ctx context.Context, input billing.DeleteInvoice
714721
}
715722

716723
// Let's see if we are talking about a gathering invoice
717-
invoiceType, err := s.adapter.GetInvoiceType(ctx, input)
724+
invoiceType, err := s.adapter.GetInvoiceType(ctx, input.Invoice)
718725
if err != nil {
719726
return billing.StandardInvoice{}, fmt.Errorf("getting invoice type: %w", err)
720727
}
721728

722729
if invoiceType == billing.InvoiceTypeGathering {
723730
return billing.StandardInvoice{}, billing.ValidationError{
724-
Err: fmt.Errorf("gathering invoice[%s]: %w", input.ID, billing.ErrInvoiceCannotDeleteGathering),
731+
Err: fmt.Errorf("gathering invoice[%s]: %w", input.Invoice.ID, billing.ErrInvoiceCannotDeleteGathering),
725732
}
726733
}
727734

728-
return s.executeTriggerOnInvoice(ctx, input, billing.TriggerDelete)
735+
return s.executeTriggerOnInvoice(
736+
ctx,
737+
input.Invoice,
738+
billing.TriggerDelete,
739+
ExecuteTriggerWithArgs(billing.DeleteInvoiceTriggerInput{
740+
Source: input.DeletionSource,
741+
}),
742+
)
729743
}
730744

731745
// updateInvoice calls the adapter to update the invoice and returns the updated invoice including any expands that are

openmeter/billing/service/invoiceupdate.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,12 @@ func validateLineEngineResult(expectedLines []billing.GenericInvoiceLine, actual
493493
return errors.Join(errs...)
494494
}
495495

496-
func (s *Service) dispatchSystemStandardLineDeletions(ctx context.Context, invoice billing.StandardInvoice, lineDiff mutableInvoiceLineDiff) error {
497-
if len(lineDiff.Deleted) == 0 {
496+
func (s *Service) dispatchSystemStandardLineDeletions(ctx context.Context, invoice billing.StandardInvoice, deletedLinesIn []billing.GenericInvoiceLine) error {
497+
if len(deletedLinesIn) == 0 {
498498
return nil
499499
}
500500

501-
deletedLines, err := slicesx.MapWithErr(lineDiff.Deleted, func(line billing.GenericInvoiceLine) (*billing.StandardLine, error) {
501+
deletedLines, err := slicesx.MapWithErr(deletedLinesIn, func(line billing.GenericInvoiceLine) (*billing.StandardLine, error) {
502502
standardLine, err := line.AsInvoiceLine().AsStandardLine()
503503
if err != nil {
504504
return nil, fmt.Errorf("converting deleted line[%s] to standard line: %w", line.GetID(), err)

openmeter/billing/service/lineengine_test.go

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,9 @@ func TestDispatchSystemStandardLineDeletionsGroupsLinesByEngine(t *testing.T) {
4141
invoiceLine := newStandardLineForLineEngineTest("line-1", billing.LineEngineTypeInvoice, true)
4242
chargeLine := newStandardLineForLineEngineTest("line-2", billing.LineEngineTypeChargeUsageBased, true)
4343

44-
require.NoError(t, svc.dispatchSystemStandardLineDeletions(t.Context(), invoice, mutableInvoiceLineDiff{
45-
OnMutableInvoiceUpdateInput: billing.OnMutableInvoiceUpdateInput{
46-
Deleted: []billing.GenericInvoiceLine{
47-
invoiceLine.AsGenericLine(),
48-
chargeLine.AsGenericLine(),
49-
},
50-
},
44+
require.NoError(t, svc.dispatchSystemStandardLineDeletions(t.Context(), invoice, []billing.GenericInvoiceLine{
45+
invoiceLine.AsGenericLine(),
46+
chargeLine.AsGenericLine(),
5147
}))
5248

5349
require.Len(t, invoiceEngine.deletedBySystemInputs, 1)
@@ -81,12 +77,8 @@ func TestDispatchSystemStandardLineDeletionsReturnsEngineError(t *testing.T) {
8177
},
8278
}
8379

84-
err := svc.dispatchSystemStandardLineDeletions(t.Context(), invoice, mutableInvoiceLineDiff{
85-
OnMutableInvoiceUpdateInput: billing.OnMutableInvoiceUpdateInput{
86-
Deleted: []billing.GenericInvoiceLine{
87-
newStandardLineForLineEngineTest("line-1", billing.LineEngineTypeInvoice, true).AsGenericLine(),
88-
},
89-
},
80+
err := svc.dispatchSystemStandardLineDeletions(t.Context(), invoice, []billing.GenericInvoiceLine{
81+
newStandardLineForLineEngineTest("line-1", billing.LineEngineTypeInvoice, true).AsGenericLine(),
9082
})
9183

9284
require.ErrorIs(t, err, errEngineFailed)
@@ -168,6 +160,79 @@ func TestOnUnsupportedCreditNoteReturnsEngineError(t *testing.T) {
168160
require.ErrorIs(t, err, errEngineFailed)
169161
}
170162

163+
func TestDeleteInvoiceSystemDeletionSourceDispatchesOnlyNonDeletedLines(t *testing.T) {
164+
invoiceEngine := &recordingLineEngine{
165+
NoopLineEngine: billingtestutils.NoopLineEngine{
166+
EngineType: billing.LineEngineTypeInvoice,
167+
},
168+
}
169+
170+
svc := &Service{
171+
lineEngines: newEngineRegistry(),
172+
}
173+
require.NoError(t, svc.RegisterLineEngine(invoiceEngine))
174+
175+
activeLine := newStandardLineForLineEngineTest("line-1", billing.LineEngineTypeInvoice, false)
176+
deletedLine := newStandardLineForLineEngineTest("line-2", billing.LineEngineTypeInvoice, true)
177+
178+
sm := &InvoiceStateMachine{
179+
Service: svc,
180+
Invoice: billing.StandardInvoice{
181+
StandardInvoiceBase: billing.StandardInvoiceBase{
182+
Namespace: "ns",
183+
ID: "invoice-1",
184+
},
185+
Lines: billing.NewStandardInvoiceLines(billing.StandardLines{
186+
activeLine,
187+
deletedLine,
188+
}),
189+
},
190+
}
191+
192+
require.NoError(t, sm.deleteInvoice(t.Context(), billing.DeleteInvoiceTriggerInput{
193+
Source: billing.ChangeSourceSystem,
194+
}))
195+
196+
require.NotNil(t, sm.Invoice.DeletedAt)
197+
require.Equal(t, billing.ChangeSourceSystem, sm.Invoice.DeletionSource)
198+
require.Len(t, invoiceEngine.deletedBySystemInputs, 1)
199+
require.Equal(t, []string{"line-1"}, lineIDs(invoiceEngine.deletedBySystemInputs[0].Lines))
200+
}
201+
202+
func TestDeleteInvoiceAPIRequestDoesNotDispatchSystemLineDeletion(t *testing.T) {
203+
invoiceEngine := &recordingLineEngine{
204+
NoopLineEngine: billingtestutils.NoopLineEngine{
205+
EngineType: billing.LineEngineTypeInvoice,
206+
},
207+
}
208+
209+
svc := &Service{
210+
lineEngines: newEngineRegistry(),
211+
}
212+
require.NoError(t, svc.RegisterLineEngine(invoiceEngine))
213+
214+
sm := &InvoiceStateMachine{
215+
Service: svc,
216+
Invoice: billing.StandardInvoice{
217+
StandardInvoiceBase: billing.StandardInvoiceBase{
218+
Namespace: "ns",
219+
ID: "invoice-1",
220+
},
221+
Lines: billing.NewStandardInvoiceLines(billing.StandardLines{
222+
newStandardLineForLineEngineTest("line-1", billing.LineEngineTypeInvoice, true),
223+
}),
224+
},
225+
}
226+
227+
require.NoError(t, sm.deleteInvoice(t.Context(), billing.DeleteInvoiceTriggerInput{
228+
Source: billing.ChangeSourceAPIRequest,
229+
}))
230+
231+
require.NotNil(t, sm.Invoice.DeletedAt)
232+
require.Equal(t, billing.ChangeSourceAPIRequest, sm.Invoice.DeletionSource)
233+
require.Empty(t, invoiceEngine.deletedBySystemInputs)
234+
}
235+
171236
func TestEngineRegistryAllowsSingleCreateLineRouter(t *testing.T) {
172237
registry := newEngineRegistry()
173238
router := staticCreateLineRouter{engine: billing.LineEngineTypeChargeFlatFee}

openmeter/billing/service/stdinvoice.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (s *Service) UpdateStandardInvoice(ctx context.Context, input billing.Updat
6161
// charges, so there is no extra line-engine notification for them here.
6262
// Deletes still need the legacy deleted-by-system notification because
6363
// the charge line updater currently relies on it to clean up realizations.
64-
if err := s.dispatchSystemStandardLineDeletions(ctx, sm.Invoice, lineDiff); err != nil {
64+
if err := s.dispatchSystemStandardLineDeletions(ctx, sm.Invoice, lineDiff.Deleted); err != nil {
6565
return fmt.Errorf("dispatching system standard line deletions: %w", err)
6666
}
6767

openmeter/billing/service/stdinvoicestate.go

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,15 @@ func allocateStateMachine() *InvoiceStateMachine {
207207
stateMachine.Configure(billing.StandardInvoiceStatusDeleteInProgress).
208208
Permit(billing.TriggerNext, billing.StandardInvoiceStatusDeleteSyncing).
209209
Permit(billing.TriggerFailed, billing.StandardInvoiceStatusDeleteFailed).
210-
OnActive(out.deleteInvoice)
210+
OnEntry(statelessx.WithParameters(out.deleteInvoice))
211211

212212
stateMachine.Configure(billing.StandardInvoiceStatusDeleteSyncing).
213213
Permit(billing.TriggerNext, billing.StandardInvoiceStatusDeleted).
214214
Permit(billing.TriggerFailed, billing.StandardInvoiceStatusDeleteFailed).
215215
OnActive(out.syncDeletedInvoice)
216216

217217
stateMachine.Configure(billing.StandardInvoiceStatusDeleteFailed).
218-
Permit(billing.TriggerRetry, billing.StandardInvoiceStatusDeleteInProgress)
218+
Permit(billing.TriggerDelete, billing.StandardInvoiceStatusDeleteInProgress)
219219

220220
stateMachine.Configure(billing.StandardInvoiceStatusDeleted)
221221

@@ -397,8 +397,14 @@ func (m *InvoiceStateMachine) StatusDetails(ctx context.Context) (billing.Standa
397397
outErr = errors.Join(outErr, err)
398398
}
399399

400-
if availableActions.Delete, err = m.calculateAvailableActionDetails(ctx, billing.TriggerDelete); err != nil {
400+
// Delete has trigger parameters and side effects, so status-details
401+
// resolution must not fire it just to discover the resulting state.
402+
if canDelete, err := m.StateMachine.CanFireCtx(ctx, billing.TriggerDelete); err != nil {
401403
outErr = errors.Join(outErr, err)
404+
} else if canDelete {
405+
availableActions.Delete = &billing.StandardInvoiceAvailableActionDetails{
406+
ResultingState: billing.StandardInvoiceStatusDeleted,
407+
}
402408
}
403409

404410
if availableActions.Retry, err = m.calculateAvailableActionDetails(ctx, billing.TriggerRetry); err != nil {
@@ -535,8 +541,8 @@ func (m *InvoiceStateMachine) AdvanceUntilStateStable(ctx context.Context) error
535541
}
536542
}
537543

538-
func (m *InvoiceStateMachine) CanFire(ctx context.Context, trigger billing.InvoiceTrigger) (bool, error) {
539-
return m.StateMachine.CanFireCtx(ctx, trigger)
544+
func (m *InvoiceStateMachine) CanFire(ctx context.Context, trigger billing.InvoiceTrigger, args ...any) (bool, error) {
545+
return m.StateMachine.CanFireCtx(ctx, trigger, args...)
540546
}
541547

542548
func (m *InvoiceStateMachine) TriggerFailed(ctx context.Context) error {
@@ -555,9 +561,9 @@ func (m *InvoiceStateMachine) TriggerFailed(ctx context.Context) error {
555561
// FireAndActivate fires the trigger and activates the new state, if activation fails it automatically
556562
// transitions to the failed state and activates that.
557563
// In addition to the activation a calculation is always performed to ensure that the invoice is up to date.
558-
func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger billing.InvoiceTrigger) error {
564+
func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger billing.InvoiceTrigger, args ...any) error {
559565
previousStatus := m.Invoice.Status
560-
if err := m.StateMachine.FireCtx(ctx, trigger); err != nil {
566+
if err := m.StateMachine.FireCtx(ctx, trigger, args...); err != nil {
561567
if m.Invoice.Status == previousStatus {
562568
return err
563569
}
@@ -867,8 +873,48 @@ func (m *InvoiceStateMachine) syncDeletedInvoice(ctx context.Context) error {
867873
})
868874
}
869875

870-
// deleteInvoice deletes the invoice
871-
func (m *InvoiceStateMachine) deleteInvoice(ctx context.Context) error {
876+
// deleteInvoice handles entering the delete lifecycle. The delete source is
877+
// passed as trigger input so the state-machine side effect is tied to the
878+
// transition that requested deletion; the invoice field is only persisted for
879+
// audit.
880+
func (m *InvoiceStateMachine) deleteInvoice(ctx context.Context, input billing.DeleteInvoiceTriggerInput) error {
881+
if err := input.Validate(); err != nil {
882+
return err
883+
}
884+
885+
// Each delete attempt owns its validation result. This keeps a retry from
886+
// being poisoned by a prior delete-sync failure.
887+
m.Invoice.ValidationIssues = nil
888+
889+
// DeletedAt is persisted before delete syncing starts. If syncing later fails
890+
// and delete is retried, the source-specific line-engine cleanup has already
891+
// run and must not be dispatched again.
892+
if m.Invoice.DeletedAt != nil {
893+
return nil
894+
}
895+
896+
m.Invoice.DeletionSource = input.Source
897+
898+
switch input.Source {
899+
case billing.ChangeSourceAPIRequest:
900+
// API deletes are user initiated invoice deletes, not system line deletes.
901+
case billing.ChangeSourceSystem:
902+
// System invoice deletes keep the invoice lines visible on the deleted
903+
// invoice, but charge-backed engines still need the same notification they
904+
// receive when system code deletes standard lines individually.
905+
if err := m.Service.dispatchSystemStandardLineDeletions(
906+
ctx,
907+
m.Invoice,
908+
lo.Filter(m.Invoice.Lines.OrEmpty(), func(line *billing.StandardLine, _ int) bool {
909+
return line != nil && line.DeletedAt == nil
910+
}).AsGenericLines(),
911+
); err != nil {
912+
return err
913+
}
914+
default:
915+
return fmt.Errorf("unsupported deletion source: %s", input.Source)
916+
}
917+
872918
m.Invoice.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC))
873919

874920
return nil

0 commit comments

Comments
 (0)