@@ -138,12 +138,21 @@ func TestOnCreditPurchaseInitiated(t *testing.T) {
138138 require .True (t , env .sumBalance (t , env .receivableSubAccount (t , costBasis )).Equal (alpacadecimal .NewFromInt (- 100 )))
139139}
140140
141- func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt (t * testing.T ) {
141+ func TestOnCreditPurchaseInitiated_FutureEffectiveGrantBackfillsAdvanceAtPurchaseTime (t * testing.T ) {
142142 env := newCreditPurchaseHandlerTestEnv (t )
143143
144+ // given:
145+ // - existing advance and a credit purchase whose remainder becomes effective later
146+ // when:
147+ // - the materialized charge initiates the purchase now
148+ // then:
149+ // - advance attribution is booked now while only the remainder is issued later
150+ purchasedAt := env .Now ()
151+ env .createAdvanceExposure (t , alpacadecimal .NewFromInt (40 ))
152+
144153 costBasis := mustDecimal (t , "0.5" )
145154 charge := env .newExternalCharge (alpacadecimal .NewFromInt (100 ), costBasis )
146- effectiveAt := charge . CreatedAt .Add (2 * time .Hour )
155+ effectiveAt := purchasedAt .Add (2 * time .Hour )
147156 effectivePeriod := timeutil.ClosedPeriod {From : effectiveAt , To : effectiveAt }
148157 charge .Intent .ServicePeriod = effectivePeriod
149158 charge .Intent .FullServicePeriod = effectivePeriod
@@ -153,10 +162,71 @@ func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T)
153162 require .NoError (t , err )
154163 require .NotEmpty (t , ref .TransactionGroupID )
155164
156- for _ , bookedAt := range env .transactionBookedAtTimes (t , ref .TransactionGroupID ) {
157- requireLedgerBookedAtEqual (t , effectiveAt , bookedAt )
158- requireLedgerBookedAtNotEqual (t , charge .CreatedAt , bookedAt )
165+ bookedAtByTemplate := env .transactionBookedAtByTemplateCode (t , ref .TransactionGroupID )
166+ for _ , template := range []transactions.TransactionTemplate {
167+ transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate {},
168+ transactions.TranslateCustomerAccruedCostBasisTemplate {},
169+ } {
170+ bookedAt := bookedAtByTemplate [transactions .TemplateCode (template )]
171+ require .Len (t , bookedAt , 1 )
172+ requireLedgerBookedAtEqual (t , purchasedAt , bookedAt [0 ])
159173 }
174+
175+ issuanceBookedAt := bookedAtByTemplate [transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {})]
176+ require .Len (t , issuanceBookedAt , 1 )
177+ requireLedgerBookedAtEqual (t , effectiveAt , issuanceBookedAt [0 ])
178+
179+ // The backfilled amount is reflected now, but the future remainder is not spendable yet.
180+ require .Equal (t , float64 (40 ), env .sumBalanceAsOf (t , env .accruedSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
181+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
182+ require .Equal (t , float64 (60 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
183+ }
184+
185+ func TestOnCreditPurchaseInitiated_SubsequentFuturePurchaseCannotOverAttributeAdvance (t * testing.T ) {
186+ env := newCreditPurchaseHandlerTestEnv (t )
187+
188+ // given:
189+ // - 100 of existing advance and two future-effective purchases of 60 each
190+ // when:
191+ // - both purchases are initiated now
192+ // then:
193+ // - the first attributes 60, the second attributes only the remaining 40, and 20 stays future issuance
194+ purchasedAt := env .Now ()
195+ effectiveAt := purchasedAt .Add (2 * time .Hour )
196+ effectivePeriod := timeutil.ClosedPeriod {From : effectiveAt , To : effectiveAt }
197+ env .createAdvanceExposure (t , alpacadecimal .NewFromInt (100 ))
198+
199+ costBasis := mustDecimal (t , "0.5" )
200+ firstCharge := env .newExternalCharge (alpacadecimal .NewFromInt (60 ), costBasis )
201+ firstCharge .ID = "01JABCDEF0123456789ABCDEFG"
202+ firstCharge .Intent .ServicePeriod = effectivePeriod
203+ firstCharge .Intent .FullServicePeriod = effectivePeriod
204+ firstCharge .Intent .BillingPeriod = effectivePeriod
205+
206+ secondCharge := env .newExternalCharge (alpacadecimal .NewFromInt (60 ), costBasis )
207+ secondCharge .ID = "01JBCDEF0123456789ABCDEFGH"
208+ secondCharge .Intent .ServicePeriod = effectivePeriod
209+ secondCharge .Intent .FullServicePeriod = effectivePeriod
210+ secondCharge .Intent .BillingPeriod = effectivePeriod
211+
212+ firstRef , err := env .handler .OnCreditPurchaseInitiated (t .Context (), firstCharge )
213+ require .NoError (t , err )
214+ secondRef , err := env .handler .OnCreditPurchaseInitiated (t .Context (), secondCharge )
215+ require .NoError (t , err )
216+
217+ require .NotContains (t , env .transactionTemplateCodes (t , firstRef .TransactionGroupID ), transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {}))
218+ secondBookedAtByTemplate := env .transactionBookedAtByTemplateCode (t , secondRef .TransactionGroupID )
219+ secondAttributionBookedAt := secondBookedAtByTemplate [transactions .TemplateCode (transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate {})]
220+ require .Len (t , secondAttributionBookedAt , 1 )
221+ requireLedgerBookedAtEqual (t , purchasedAt , secondAttributionBookedAt [0 ])
222+ secondIssuanceBookedAt := secondBookedAtByTemplate [transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {})]
223+ require .Len (t , secondIssuanceBookedAt , 1 )
224+ requireLedgerBookedAtEqual (t , effectiveAt , secondIssuanceBookedAt [0 ])
225+
226+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .unknownReceivableSubAccount (t ), purchasedAt ).InexactFloat64 ())
227+ require .Equal (t , float64 (100 ), env .sumBalanceAsOf (t , env .accruedSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
228+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
229+ require .Equal (t , float64 (20 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
160230}
161231
162232func TestOnCreditPurchaseInitiated_SeparatesSourceChargeBuckets (t * testing.T ) {
@@ -872,6 +942,32 @@ func (e *creditPurchaseHandlerTestEnv) transactionBookedAtTimes(t *testing.T, gr
872942 return out
873943}
874944
945+ func (e * creditPurchaseHandlerTestEnv ) transactionBookedAtByTemplateCode (t * testing.T , groupID string ) map [string ][]time.Time {
946+ t .Helper ()
947+
948+ transactionRows , err := e .DB .LedgerTransaction .Query ().
949+ Where (
950+ ledgertransactiondb .Namespace (e .Namespace ),
951+ ledgertransactiondb .GroupID (groupID ),
952+ ).
953+ Order (
954+ ledgertransactiondb .ByCreatedAt (),
955+ ledgertransactiondb .ByID (),
956+ ).
957+ All (t .Context ())
958+ require .NoError (t , err )
959+ require .NotEmpty (t , transactionRows , "expected at least one ledger transaction for group" )
960+
961+ out := make (map [string ][]time.Time , len (transactionRows ))
962+ for _ , transactionRow := range transactionRows {
963+ code , err := ledger .TransactionTemplateCodeFromAnnotations (transactionRow .Annotations )
964+ require .NoError (t , err )
965+ out [code ] = append (out [code ], transactionRow .BookedAt )
966+ }
967+
968+ return out
969+ }
970+
875971func (e * creditPurchaseHandlerTestEnv ) transactionTemplateCodes (t * testing.T , groupID string ) []string {
876972 t .Helper ()
877973
0 commit comments